mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
feat: show top affected operations and clients for a schema change in schema checks (#3504)
Co-authored-by: Kamil Kisiela <kamil.kisiela@gmail.com> Co-authored-by: Laurin Quast <laurinquast@googlemail.com>
This commit is contained in:
parent
5a1bef77a1
commit
cd1271088a
48 changed files with 1747 additions and 635 deletions
5
.changeset/nine-beans-yawn.md
Normal file
5
.changeset/nine-beans-yawn.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@graphql-hive/cli": minor
|
||||
---
|
||||
|
||||
Show which breaking changes are safe on usage
|
||||
|
|
@ -138,6 +138,8 @@ const config: CodegenConfig = {
|
|||
'../modules/schema/providers/contracts#ContractCheck as ContractCheckMapper',
|
||||
ContractVersion:
|
||||
'../modules/schema/providers/contracts#ContractVersion as ContractVersionMapper',
|
||||
BreakingChangeMetadataTarget:
|
||||
'../shared/mappers#BreakingChangeMetadataTarget as BreakingChangeMetadataTargetMapper',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -188,7 +190,7 @@ const config: CodegenConfig = {
|
|||
},
|
||||
// CLI
|
||||
'./packages/libraries/cli/src/gql/': {
|
||||
documents: ['./packages/libraries/cli/src/commands/**/*.ts'],
|
||||
documents: ['./packages/libraries/cli/src/(commands|helpers)/**/*.ts'],
|
||||
preset: 'client',
|
||||
plugins: [],
|
||||
config: {
|
||||
|
|
|
|||
|
|
@ -222,6 +222,7 @@ test.concurrent(
|
|||
expect(changes[0]).toMatchInlineSnapshot(`
|
||||
{
|
||||
approvalMetadata: null,
|
||||
breakingChangeSchemaCoordinate: Query.bruv,
|
||||
criticality: BREAKING,
|
||||
id: b3cb5845edf64492571c7b5c5857b7f9,
|
||||
isSafeBasedOnUsage: false,
|
||||
|
|
@ -235,6 +236,7 @@ test.concurrent(
|
|||
path: Query.bruv,
|
||||
reason: Removing a field is a breaking change. It is preferable to deprecate the field before removing it.,
|
||||
type: FIELD_REMOVED,
|
||||
usageStatistics: null,
|
||||
}
|
||||
`);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@graphql-hive/core": "0.2.3",
|
||||
"@graphql-inspector/core": "5.0.2",
|
||||
"@graphql-inspector/core": "5.1.0-alpha-20231208113249-34700c8a",
|
||||
"@graphql-tools/code-file-loader": "~8.1.0",
|
||||
"@graphql-tools/graphql-file-loader": "~8.0.0",
|
||||
"@graphql-tools/json-file-loader": "~8.0.0",
|
||||
|
|
|
|||
|
|
@ -32,8 +32,15 @@ const schemaCheckMutation = graphql(/* GraphQL */ `
|
|||
message(withSafeBasedOnUsageNote: false)
|
||||
criticality
|
||||
isSafeBasedOnUsage
|
||||
approval {
|
||||
approvedBy {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
...RenderChanges_schemaChanges
|
||||
}
|
||||
schemaCheck {
|
||||
webUrl
|
||||
|
|
@ -48,6 +55,7 @@ const schemaCheckMutation = graphql(/* GraphQL */ `
|
|||
isSafeBasedOnUsage
|
||||
}
|
||||
total
|
||||
...RenderChanges_schemaChanges
|
||||
}
|
||||
warnings {
|
||||
nodes {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const schemaPublishMutation = graphql(/* GraphQL */ `
|
|||
isSafeBasedOnUsage
|
||||
}
|
||||
total
|
||||
...RenderChanges_schemaChanges
|
||||
}
|
||||
}
|
||||
... on SchemaPublishError @skip(if: $usesGitHubApp) {
|
||||
|
|
@ -37,6 +38,7 @@ const schemaPublishMutation = graphql(/* GraphQL */ `
|
|||
isSafeBasedOnUsage
|
||||
}
|
||||
total
|
||||
...RenderChanges_schemaChanges
|
||||
}
|
||||
errors {
|
||||
nodes {
|
||||
|
|
|
|||
|
|
@ -6,13 +6,8 @@ import { JsonFileLoader } from '@graphql-tools/json-file-loader';
|
|||
import { loadTypedefs } from '@graphql-tools/load';
|
||||
import { UrlLoader } from '@graphql-tools/url-loader';
|
||||
import baseCommand from '../base-command';
|
||||
import {
|
||||
CriticalityLevel,
|
||||
SchemaChange,
|
||||
SchemaChangeConnection,
|
||||
SchemaErrorConnection,
|
||||
SchemaWarningConnection,
|
||||
} from '../gql/graphql';
|
||||
import { FragmentType, graphql, useFragment as unmaskFragment } from '../gql';
|
||||
import { CriticalityLevel, SchemaErrorConnection, SchemaWarningConnection } from '../gql/graphql';
|
||||
|
||||
const indent = ' ';
|
||||
|
||||
|
|
@ -31,13 +26,50 @@ export function renderErrors(this: baseCommand, errors: SchemaErrorConnection) {
|
|||
});
|
||||
}
|
||||
|
||||
export function renderChanges(this: baseCommand, changes: SchemaChangeConnection) {
|
||||
const RenderChanges_SchemaChanges = graphql(`
|
||||
fragment RenderChanges_schemaChanges on SchemaChangeConnection {
|
||||
total
|
||||
nodes {
|
||||
criticality
|
||||
isSafeBasedOnUsage
|
||||
message(withSafeBasedOnUsageNote: false)
|
||||
approval {
|
||||
approvedBy {
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export function renderChanges(
|
||||
this: baseCommand,
|
||||
maskedChanges: FragmentType<typeof RenderChanges_SchemaChanges>,
|
||||
) {
|
||||
const changes = unmaskFragment(RenderChanges_SchemaChanges, maskedChanges);
|
||||
type ChangeType = (typeof changes)['nodes'][number];
|
||||
|
||||
const filterChangesByLevel = (level: CriticalityLevel) => {
|
||||
return (change: SchemaChange) => change.criticality === level;
|
||||
return (change: ChangeType) => change.criticality === level;
|
||||
};
|
||||
const writeChanges = (changes: SchemaChange[]) => {
|
||||
const writeChanges = (changes: ChangeType[]) => {
|
||||
changes.forEach(change => {
|
||||
this.log(String(indent), criticalityMap[change.criticality], this.bolderize(change.message));
|
||||
const messageParts = [
|
||||
String(indent),
|
||||
criticalityMap[change.isSafeBasedOnUsage ? CriticalityLevel.Safe : change.criticality],
|
||||
this.bolderize(change.message),
|
||||
];
|
||||
|
||||
if (change.isSafeBasedOnUsage) {
|
||||
messageParts.push(colors.green('(Safe based on usage ✓)'));
|
||||
}
|
||||
if (change.approval) {
|
||||
messageParts.push(
|
||||
colors.green(`(Approved by ${change.approval.approvedBy?.displayName ?? '<unknown>'} ✓)`),
|
||||
);
|
||||
}
|
||||
|
||||
this.log(...messageParts);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import type { MigrationExecutor } from '../pg-migrator';
|
||||
|
||||
export default {
|
||||
name: '2024.02.19T00.00.01.schema-check-store-breaking-change-metadata.ts',
|
||||
noTransaction: true,
|
||||
run: ({ sql }) => sql`
|
||||
ALTER TABLE "schema_checks"
|
||||
ADD COLUMN IF NOT EXISTS "conditional_breaking_change_metadata" JSONB
|
||||
;
|
||||
|
||||
ALTER TABLE "schema_versions"
|
||||
ADD COLUMN IF NOT EXISTS "conditional_breaking_change_metadata" JSONB
|
||||
;
|
||||
`,
|
||||
} satisfies MigrationExecutor;
|
||||
|
|
@ -61,6 +61,7 @@ import migration_2023_11_20T10_00_00_organization_member_roles from './actions/2
|
|||
import migration_2024_01_08T_10_00_00_schema_version_diff_schema_version_id from './actions/2024.01.08T10-00-00.schema-version-diff-schema-version-id';
|
||||
import migration_2024_01_12_01T00_00_00_contracts from './actions/2024.01.26T00.00.00.contracts';
|
||||
import migration_2024_01_12_01T00_00_00_schema_check_pagination_index_update from './actions/2024.01.26T00.00.01.schema-check-pagination-index-update';
|
||||
import migration_2024_02_19_01T00_00_00_schema_check_store_breaking_change_metadata from './actions/2024.02.19T00.00.01.schema-check-store-breaking-change-metadata';
|
||||
import { runMigrations } from './pg-migrator';
|
||||
|
||||
export const runPGMigrations = (args: { slonik: DatabasePool; runTo?: string }) =>
|
||||
|
|
@ -130,5 +131,6 @@ export const runPGMigrations = (args: { slonik: DatabasePool; runTo?: string })
|
|||
migration_2024_01_08T_10_00_00_schema_version_diff_schema_version_id,
|
||||
migration_2024_01_12_01T00_00_00_contracts,
|
||||
migration_2024_01_12_01T00_00_00_schema_check_pagination_index_update,
|
||||
migration_2024_02_19_01T00_00_00_schema_check_store_breaking_change_metadata,
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ describe('schema check purging', async () => {
|
|||
githubSha: null,
|
||||
manualApprovalUserId: null,
|
||||
schemaPolicyWarnings: null,
|
||||
conditionalBreakingChangeMetadata: null,
|
||||
});
|
||||
|
||||
let schemaCheckCount = await db.oneFirst<number>(
|
||||
|
|
@ -162,6 +163,7 @@ describe('schema check purging', async () => {
|
|||
githubSha: null,
|
||||
manualApprovalUserId: null,
|
||||
schemaPolicyWarnings: null,
|
||||
conditionalBreakingChangeMetadata: null,
|
||||
});
|
||||
|
||||
await storage.createSchemaCheck({
|
||||
|
|
@ -186,6 +188,7 @@ describe('schema check purging', async () => {
|
|||
githubSha: null,
|
||||
manualApprovalUserId: null,
|
||||
schemaPolicyWarnings: null,
|
||||
conditionalBreakingChangeMetadata: null,
|
||||
});
|
||||
|
||||
let schemaCheckCount = await db.oneFirst<number>(
|
||||
|
|
@ -267,6 +270,7 @@ describe('schema check purging', async () => {
|
|||
githubSha: null,
|
||||
manualApprovalUserId: null,
|
||||
schemaPolicyWarnings: null,
|
||||
conditionalBreakingChangeMetadata: null,
|
||||
});
|
||||
|
||||
await storage.createSchemaCheck({
|
||||
|
|
@ -291,6 +295,7 @@ describe('schema check purging', async () => {
|
|||
githubSha: null,
|
||||
manualApprovalUserId: null,
|
||||
schemaPolicyWarnings: null,
|
||||
conditionalBreakingChangeMetadata: null,
|
||||
});
|
||||
|
||||
let schemaCheckCount = await db.oneFirst<number>(
|
||||
|
|
@ -382,6 +387,7 @@ describe('schema check purging', async () => {
|
|||
githubSha: null,
|
||||
manualApprovalUserId: null,
|
||||
schemaPolicyWarnings: null,
|
||||
conditionalBreakingChangeMetadata: null,
|
||||
});
|
||||
|
||||
await storage.createSchemaCheck({
|
||||
|
|
@ -406,6 +412,7 @@ describe('schema check purging', async () => {
|
|||
githubSha: null,
|
||||
manualApprovalUserId: null,
|
||||
schemaPolicyWarnings: null,
|
||||
conditionalBreakingChangeMetadata: null,
|
||||
});
|
||||
|
||||
let schemaCheckCount = await db.oneFirst<number>(
|
||||
|
|
@ -552,6 +559,7 @@ describe('schema check purging', async () => {
|
|||
githubSha: null,
|
||||
manualApprovalUserId: null,
|
||||
schemaPolicyWarnings: null,
|
||||
conditionalBreakingChangeMetadata: null,
|
||||
});
|
||||
|
||||
await storage.createSchemaCheck({
|
||||
|
|
@ -576,6 +584,7 @@ describe('schema check purging', async () => {
|
|||
githubSha: null,
|
||||
manualApprovalUserId: null,
|
||||
schemaPolicyWarnings: null,
|
||||
conditionalBreakingChangeMetadata: null,
|
||||
});
|
||||
|
||||
let schemaCheckCount = await db.oneFirst<number>(
|
||||
|
|
@ -719,6 +728,7 @@ describe('schema check purging', async () => {
|
|||
githubSha: null,
|
||||
manualApprovalUserId: null,
|
||||
schemaPolicyWarnings: null,
|
||||
conditionalBreakingChangeMetadata: null,
|
||||
});
|
||||
|
||||
let sdlStoreCount = await db.oneFirst<number>(sql`SELECT count(*) as total FROM sdl_store`);
|
||||
|
|
@ -828,6 +838,7 @@ describe('schema check purging', async () => {
|
|||
githubSha: null,
|
||||
manualApprovalUserId: null,
|
||||
schemaPolicyWarnings: null,
|
||||
conditionalBreakingChangeMetadata: null,
|
||||
});
|
||||
|
||||
let schemaCheckCount = await db.oneFirst<number>(
|
||||
|
|
@ -983,6 +994,7 @@ describe('schema check purging', async () => {
|
|||
githubSha: null,
|
||||
manualApprovalUserId: null,
|
||||
schemaPolicyWarnings: null,
|
||||
conditionalBreakingChangeMetadata: null,
|
||||
});
|
||||
|
||||
await storage.createSchemaCheck({
|
||||
|
|
@ -1026,6 +1038,7 @@ describe('schema check purging', async () => {
|
|||
githubSha: null,
|
||||
manualApprovalUserId: null,
|
||||
schemaPolicyWarnings: null,
|
||||
conditionalBreakingChangeMetadata: null,
|
||||
});
|
||||
|
||||
let schemaCheckCount = await db.oneFirst<number>(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export interface QueryResponse<T> {
|
|||
statistics?: {
|
||||
elapsed: number;
|
||||
};
|
||||
|
||||
exception?: string;
|
||||
}
|
||||
|
||||
export type RowOf<T extends QueryResponse<any>> = T extends QueryResponse<infer R> ? R : never;
|
||||
|
|
@ -144,6 +146,12 @@ export class ClickHouse {
|
|||
https: httpsAgent,
|
||||
},
|
||||
})
|
||||
.then(response => {
|
||||
if (response.exception) {
|
||||
throw new Error(response.exception);
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(error => {
|
||||
this.logger.error(
|
||||
`Failed to run ClickHouse query, executionId: %s, code: %s , error name: %s, message: %s`,
|
||||
|
|
@ -164,7 +172,6 @@ export class ClickHouse {
|
|||
span?.finish();
|
||||
});
|
||||
const endedAt = (Date.now() - startedAt) / 1000;
|
||||
|
||||
this.config.onReadEnd?.(queryId, {
|
||||
totalSeconds: endedAt,
|
||||
elapsedSeconds: response.statistics?.elapsed,
|
||||
|
|
|
|||
|
|
@ -366,7 +366,7 @@ export class OperationsManager {
|
|||
fields,
|
||||
period,
|
||||
targetIds: Array.isArray(target) ? target : [target],
|
||||
excludedClients,
|
||||
excludedClients: excludedClients ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Injectable } from 'graphql-modules';
|
|||
import * as z from 'zod';
|
||||
import { batch } from '@theguild/buddy';
|
||||
import type { DateRange } from '../../../shared/entities';
|
||||
import { batchBy } from '../../../shared/helpers';
|
||||
import { Logger } from '../../shared/providers/logger';
|
||||
import { ClickHouse, RowOf, sql } from './clickhouse-client';
|
||||
import { calculateTimeWindow } from './helpers';
|
||||
|
|
@ -238,7 +239,7 @@ export class OperationsReader {
|
|||
target: string | readonly string[];
|
||||
period: DateRange;
|
||||
operations?: readonly string[];
|
||||
excludedClients?: readonly string[];
|
||||
excludedClients?: readonly string[] | null;
|
||||
}): Promise<Record<string, number>> {
|
||||
const coordinates = fields.map(selector => this.makeId(selector));
|
||||
const conditions = [sql`(coordinate IN (${sql.array(coordinates, 'String')}))`];
|
||||
|
|
@ -374,7 +375,7 @@ export class OperationsReader {
|
|||
async readFieldListStats(args: {
|
||||
targetIds: readonly string[];
|
||||
period: DateRange;
|
||||
excludedClients?: readonly string[];
|
||||
excludedClients: readonly string[] | null;
|
||||
fields: readonly {
|
||||
type: string;
|
||||
field?: string | null;
|
||||
|
|
@ -1070,6 +1071,307 @@ export class OperationsReader {
|
|||
});
|
||||
}
|
||||
|
||||
/** Get the total amount of requests for a list of targets for a period. */
|
||||
async getTotalAmountOfRequests(args: {
|
||||
targetIds: readonly string[];
|
||||
excludedClients: null | readonly string[];
|
||||
period: DateRange;
|
||||
}) {
|
||||
const TotalCountModel = z
|
||||
.tuple([z.object({ amountOfRequests: z.string() })])
|
||||
.transform(data => ensureNumber(data[0].amountOfRequests));
|
||||
|
||||
return await this.clickHouse
|
||||
.query<unknown>({
|
||||
queryId: 'getTotalCountForSchemaCoordinates',
|
||||
query: sql`
|
||||
SELECT
|
||||
SUM("operations_daily"."total") AS "amountOfRequests"
|
||||
FROM
|
||||
"operations_daily"
|
||||
PREWHERE
|
||||
"operations_daily"."target" IN (${sql.array(args.targetIds, 'String')})
|
||||
AND "operations_daily"."timestamp" >= toDateTime(${formatDate(args.period.from)}, 'UTC')
|
||||
AND "operations_daily"."timestamp" <= toDateTime(${formatDate(args.period.to)}, 'UTC')
|
||||
${args.excludedClients ? sql`AND "operations_daily"."client_name" NOT IN (${sql.array(args.excludedClients, 'String')})` : sql``}
|
||||
`,
|
||||
timeout: 10_000,
|
||||
})
|
||||
.then(result => TotalCountModel.parse(result.data));
|
||||
}
|
||||
|
||||
/** Result array retains the order of the input `args.schemaCoordinates`. */
|
||||
private async _getTopOperationsForSchemaCoordinates(args: {
|
||||
targetIds: readonly string[];
|
||||
excludedClients: null | readonly string[];
|
||||
period: DateRange;
|
||||
schemaCoordinates: string[];
|
||||
requestCountThreshold: number;
|
||||
}) {
|
||||
const RecordArrayType = z.array(
|
||||
z.object({
|
||||
coordinate: z.string(),
|
||||
hash: z.string(),
|
||||
name: z.string(),
|
||||
count: z.string().transform(ensureNumber),
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.debug('Fetching top operations for schema coordinates (args=%o)', args);
|
||||
|
||||
/**
|
||||
* top_operations_by_coordinates -> get the top operations for schema coordinates, we need to right join operations_daily as coordinates_daily does not contain the client_names column
|
||||
*/
|
||||
const results = await this.clickHouse
|
||||
.query<unknown>({
|
||||
queryId: '_getTopOperationsForSchemaCoordinates',
|
||||
query: sql`
|
||||
WITH "top_operations_by_coordinates" AS (
|
||||
SELECT
|
||||
"coordinates_daily"."coordinate" AS "coordinate",
|
||||
"coordinates_daily"."hash" AS "hash",
|
||||
SUM("coordinates_daily"."total") AS "total"
|
||||
FROM
|
||||
"coordinates_daily"
|
||||
RIGHT JOIN (
|
||||
SELECT
|
||||
"hash"
|
||||
FROM
|
||||
"operations_daily"
|
||||
PREWHERE
|
||||
"target" IN (${sql.array(args.targetIds, 'String')})
|
||||
AND "timestamp" >= toDateTime(${formatDate(args.period.from)}, 'UTC')
|
||||
AND "timestamp" <= toDateTime(${formatDate(args.period.to)}, 'UTC')
|
||||
${args.excludedClients ? sql`AND "client_name" NOT IN (${sql.array(args.excludedClients, 'String')})` : sql``}
|
||||
LIMIT 1
|
||||
BY "hash"
|
||||
) AS "coordinates_daily_join" ON "coordinates_daily"."hash" = "coordinates_daily_join"."hash"
|
||||
PREWHERE
|
||||
"coordinates_daily"."target" IN (${sql.array(args.targetIds, 'String')})
|
||||
AND "coordinates_daily"."timestamp" >= toDateTime(${formatDate(args.period.from)}, 'UTC')
|
||||
AND "coordinates_daily"."timestamp" <= toDateTime(${formatDate(args.period.to)}, 'UTC')
|
||||
AND "coordinates_daily"."coordinate" IN (${sql.array(args.schemaCoordinates, 'String')})
|
||||
GROUP BY
|
||||
"coordinates_daily"."coordinate",
|
||||
"coordinates_daily"."hash"
|
||||
HAVING SUM("coordinates_daily"."total") >= ${String(args.requestCountThreshold)}
|
||||
ORDER BY
|
||||
"total" DESC,
|
||||
"coordinates_daily"."hash" DESC
|
||||
LIMIT 10
|
||||
BY "coordinates_daily"."coordinate"
|
||||
)
|
||||
SELECT
|
||||
"top_operations_by_coordinates"."coordinate" AS "coordinate",
|
||||
"top_operations_by_coordinates"."hash" AS "hash",
|
||||
"operation_names"."name" AS "name",
|
||||
"top_operations_by_coordinates"."total" AS "count"
|
||||
FROM
|
||||
"top_operations_by_coordinates"
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
"operation_collection_details"."name",
|
||||
"operation_collection_details"."hash"
|
||||
FROM
|
||||
"operation_collection_details"
|
||||
WHERE
|
||||
"operation_collection_details"."target" IN (${sql.array(args.targetIds, 'String')})
|
||||
AND "operation_collection_details"."hash" IN (SELECT DISTINCT "hash" FROM "top_operations_by_coordinates")
|
||||
LIMIT 1
|
||||
BY "operation_collection_details"."hash"
|
||||
) AS "operation_names"
|
||||
ON "operation_names"."hash" = "top_operations_by_coordinates"."hash"
|
||||
ORDER BY
|
||||
"top_operations_by_coordinates"."coordinate" DESC,
|
||||
"top_operations_by_coordinates"."total" DESC
|
||||
`,
|
||||
timeout: 10_000,
|
||||
})
|
||||
.then(result => RecordArrayType.parse(result.data));
|
||||
|
||||
const operationsBySchemaCoordinate = new Map<
|
||||
string,
|
||||
Array<{
|
||||
hash: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}>
|
||||
>();
|
||||
|
||||
for (const result of results) {
|
||||
let records = operationsBySchemaCoordinate.get(result.coordinate);
|
||||
if (!records) {
|
||||
records = [];
|
||||
operationsBySchemaCoordinate.set(result.coordinate, records);
|
||||
}
|
||||
records.push({
|
||||
hash: result.hash,
|
||||
name: result.name,
|
||||
count: result.count,
|
||||
});
|
||||
}
|
||||
|
||||
return args.schemaCoordinates.map(
|
||||
schemaCoordinate => operationsBySchemaCoordinate.get(schemaCoordinate) ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/** Get the top operations for a given schema coordinate (uses batch loader underneath). */
|
||||
getTopOperationsForSchemaCoordinate = batchBy<
|
||||
{
|
||||
targetIds: readonly string[];
|
||||
excludedClients: null | readonly string[];
|
||||
period: DateRange;
|
||||
schemaCoordinate: string;
|
||||
requestCountThreshold: number;
|
||||
},
|
||||
Array<{
|
||||
hash: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}> | null
|
||||
>(
|
||||
item =>
|
||||
`${item.targetIds.join(',')}-${item.excludedClients?.join(',') ?? ''}-${item.period.from.toISOString()}-${item.period.to.toISOString()}-${item.requestCountThreshold}`,
|
||||
async items => {
|
||||
const schemaCoordinates = items.map(item => item.schemaCoordinate);
|
||||
return await this._getTopOperationsForSchemaCoordinates({
|
||||
targetIds: items[0].targetIds,
|
||||
excludedClients: items[0].excludedClients,
|
||||
period: items[0].period,
|
||||
requestCountThreshold: items[0].requestCountThreshold,
|
||||
schemaCoordinates,
|
||||
}).then(result => result.map(result => Promise.resolve(result)));
|
||||
},
|
||||
);
|
||||
|
||||
/** Result array retains the order of the input `args.schemaCoordinates`. */
|
||||
private async _getTopClientsForSchemaCoordinates(args: {
|
||||
targetIds: readonly string[];
|
||||
excludedClients: null | readonly string[];
|
||||
period: DateRange;
|
||||
schemaCoordinates: string[];
|
||||
}) {
|
||||
const RecordArrayType = z.array(
|
||||
z.object({
|
||||
coordinate: z.string(),
|
||||
name: z.string(),
|
||||
count: z.string().transform(ensureNumber),
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.debug('Fetching top clients for schema coordinates (args=%o)', args);
|
||||
|
||||
const results = await this.clickHouse
|
||||
.query<unknown>({
|
||||
queryId: '_getTopClientsForSchemaCoordinates',
|
||||
query: sql`
|
||||
WITH "coordinates_to_client_name_mapping" AS (
|
||||
SELECT
|
||||
"coordinates_daily"."coordinate" AS "coordinate",
|
||||
"operations_daily_filtered"."client_name" AS "client_name"
|
||||
FROM
|
||||
"coordinates_daily"
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
"operations_daily"."hash",
|
||||
"operations_daily"."client_name"
|
||||
FROM
|
||||
"operations_daily"
|
||||
PREWHERE
|
||||
"operations_daily"."target" IN (${sql.array(args.targetIds, 'String')})
|
||||
AND "operations_daily"."timestamp" >= toDateTime(${formatDate(args.period.from)}, 'UTC')
|
||||
AND "operations_daily"."timestamp" <= toDateTime(${formatDate(args.period.to)}, 'UTC')
|
||||
${args.excludedClients ? sql`AND "operations_daily"."client_name" NOT IN (${sql.array(args.excludedClients, 'String')})` : sql``}
|
||||
LIMIT 1
|
||||
BY
|
||||
"operations_daily"."hash",
|
||||
"operations_daily"."client_name"
|
||||
) AS "operations_daily_filtered"
|
||||
ON "operations_daily_filtered"."hash" = "coordinates_daily"."hash"
|
||||
PREWHERE
|
||||
"coordinates_daily"."target" IN (${sql.array(args.targetIds, 'String')})
|
||||
AND "coordinates_daily"."timestamp" >= toDateTime(${formatDate(args.period.from)}, 'UTC')
|
||||
AND "coordinates_daily"."timestamp" <= toDateTime(${formatDate(args.period.to)}, 'UTC')
|
||||
AND "coordinates_daily"."coordinate" IN (${sql.array(args.schemaCoordinates, 'String')})
|
||||
LIMIT 1
|
||||
BY
|
||||
"coordinates_daily"."coordinate",
|
||||
"operations_daily_filtered"."client_name"
|
||||
)
|
||||
SELECT
|
||||
"coordinates_to_client_name_mapping"."coordinate" AS "coordinate",
|
||||
"clients_daily"."client_name" AS "name",
|
||||
SUM("clients_daily"."total") AS "count"
|
||||
FROM
|
||||
"clients_daily"
|
||||
LEFT JOIN
|
||||
"coordinates_to_client_name_mapping"
|
||||
ON "clients_daily"."client_name" = "coordinates_to_client_name_mapping"."client_name"
|
||||
PREWHERE
|
||||
"clients_daily"."target" IN (${sql.array(args.targetIds, 'String')})
|
||||
AND "clients_daily"."timestamp" >= toDateTime(${formatDate(args.period.from)}, 'UTC')
|
||||
AND "clients_daily"."timestamp" <= toDateTime(${formatDate(args.period.to)}, 'UTC')
|
||||
${args.excludedClients ? sql`AND "clients_daily"."client_name" NOT IN (${sql.array(args.excludedClients, 'String')})` : sql``}
|
||||
GROUP BY
|
||||
"coordinates_to_client_name_mapping"."coordinate",
|
||||
"clients_daily"."client_name"
|
||||
LIMIT 10
|
||||
BY
|
||||
"coordinates_to_client_name_mapping"."coordinate",
|
||||
"clients_daily"."client_name"
|
||||
`,
|
||||
timeout: 10_000,
|
||||
})
|
||||
.then(result => RecordArrayType.parse(result.data));
|
||||
|
||||
const operationsBySchemaCoordinate = new Map<
|
||||
string,
|
||||
Array<{
|
||||
name: string;
|
||||
count: number;
|
||||
}>
|
||||
>();
|
||||
|
||||
for (const result of results) {
|
||||
let records = operationsBySchemaCoordinate.get(result.coordinate);
|
||||
if (!records) {
|
||||
records = [];
|
||||
operationsBySchemaCoordinate.set(result.coordinate, records);
|
||||
}
|
||||
records.push({
|
||||
name: result.name,
|
||||
count: result.count,
|
||||
});
|
||||
}
|
||||
|
||||
return args.schemaCoordinates.map(
|
||||
schemaCoordinate => operationsBySchemaCoordinate.get(schemaCoordinate) ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
getTopClientsForSchemaCoordinate = batchBy<
|
||||
{
|
||||
targetIds: readonly string[];
|
||||
excludedClients: null | readonly string[];
|
||||
period: DateRange;
|
||||
schemaCoordinate: string;
|
||||
},
|
||||
Array<{ name: string; count: number }> | null
|
||||
>(
|
||||
item =>
|
||||
`${item.targetIds.join(',')}-${item.excludedClients?.join(',') ?? ''}-${item.period.from.toISOString()}-${item.period.to.toISOString()}`,
|
||||
async items => {
|
||||
const schemaCoordinates = items.map(item => item.schemaCoordinate);
|
||||
return await this._getTopClientsForSchemaCoordinates({
|
||||
targetIds: items[0].targetIds,
|
||||
excludedClients: items[0].excludedClients,
|
||||
period: items[0].period,
|
||||
schemaCoordinates,
|
||||
}).then(result => result.map(result => Promise.resolve(result)));
|
||||
},
|
||||
);
|
||||
|
||||
async countClientVersions({
|
||||
target,
|
||||
period,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createModule } from 'graphql-modules';
|
||||
import { BreakingSchemaChangeUsageHelper } from './providers/breaking-schema-changes-helper';
|
||||
import { Contracts } from './providers/contracts';
|
||||
import { ContractsManager } from './providers/contracts-manager';
|
||||
import { Inspector } from './providers/inspector';
|
||||
|
|
@ -28,6 +29,7 @@ export const schemaModule = createModule({
|
|||
Contracts,
|
||||
ContractsManager,
|
||||
SchemaCheckManager,
|
||||
BreakingSchemaChangeUsageHelper,
|
||||
...orchestrators,
|
||||
...models,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
export function formatPercentage(percentage: number): string {
|
||||
if (percentage < 0.01) {
|
||||
return '<0.01%';
|
||||
}
|
||||
return `${percentage.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
const symbols = ['', 'K', 'M', 'G', 'T', 'P', 'E'];
|
||||
|
||||
export function formatNumber(value: number): string {
|
||||
// what tier? (determines SI symbol)
|
||||
const tier = (Math.log10(Math.abs(value)) / 3) | 0;
|
||||
|
||||
// if zero, we don't need a suffix
|
||||
if (tier === 0) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
// get suffix and determine scale
|
||||
const suffix = symbols[tier];
|
||||
const scale = Math.pow(10, tier * 3);
|
||||
|
||||
// scale the number
|
||||
const scaled = value / scale;
|
||||
|
||||
// format number and add suffix
|
||||
return scaled.toFixed(1) + suffix;
|
||||
}
|
||||
|
|
@ -367,6 +367,73 @@ export default gql`
|
|||
Whether the breaking change is safe based on usage data.
|
||||
"""
|
||||
isSafeBasedOnUsage: Boolean!
|
||||
"""
|
||||
Usage statistics about the schema change if it is not safe based on usage.
|
||||
The statistics are determined based on the breaking change configuration.
|
||||
The usage statistics are only available for breaking changes and only represent a snapshot of the usage data at the time of the schema check/schema publish.
|
||||
"""
|
||||
usageStatistics: SchemaChangeUsageStatistics
|
||||
}
|
||||
|
||||
type SchemaChangeUsageStatistics {
|
||||
"""
|
||||
List of the top operations that are affected by this schema change.
|
||||
"""
|
||||
topAffectedOperations: [SchemaChangeUsageStatisticsAffectedOperation!]!
|
||||
"""
|
||||
List of top clients that are affected by this schema change.
|
||||
"""
|
||||
topAffectedClients: [SchemaChangeUsageStatisticsAffectedClient!]!
|
||||
}
|
||||
|
||||
type SchemaChangeUsageStatisticsAffectedOperation {
|
||||
"""
|
||||
Name of the operation.
|
||||
"""
|
||||
name: String!
|
||||
"""
|
||||
Hash of the operation.
|
||||
"""
|
||||
hash: String!
|
||||
"""
|
||||
The number of times the operation was called in the period.
|
||||
"""
|
||||
count: Float!
|
||||
"""
|
||||
Human readable count value.
|
||||
"""
|
||||
countFormatted: String!
|
||||
"""
|
||||
The percentage share of the operation of the total traffic.
|
||||
"""
|
||||
percentage: Float!
|
||||
"""
|
||||
Human readable percentage value.
|
||||
"""
|
||||
percentageFormatted: String!
|
||||
}
|
||||
|
||||
type SchemaChangeUsageStatisticsAffectedClient {
|
||||
"""
|
||||
Name of the client.
|
||||
"""
|
||||
name: String!
|
||||
"""
|
||||
The number of times the client called the operation in the period.
|
||||
"""
|
||||
count: Float!
|
||||
"""
|
||||
Human readable count value.
|
||||
"""
|
||||
countFormatted: String!
|
||||
"""
|
||||
The percentage share of the client of the total traffic.
|
||||
"""
|
||||
percentage: Float!
|
||||
"""
|
||||
Human readable percentage value.
|
||||
"""
|
||||
percentageFormatted: String!
|
||||
}
|
||||
|
||||
type SchemaChangeApproval {
|
||||
|
|
@ -404,6 +471,35 @@ export default gql`
|
|||
total: Int!
|
||||
}
|
||||
|
||||
type BreakingChangeMetadataTarget {
|
||||
name: String!
|
||||
target: Target
|
||||
}
|
||||
|
||||
type SchemaCheckConditionalBreakingChangeMetadataSettings {
|
||||
retentionInDays: Int!
|
||||
percentage: Float!
|
||||
excludedClientNames: [String!]
|
||||
targets: [BreakingChangeMetadataTarget!]!
|
||||
}
|
||||
|
||||
type SchemaCheckConditionalBreakingChangeMetadataUsage {
|
||||
"""
|
||||
Total amount of requests for the settings and period.
|
||||
"""
|
||||
totalRequestCount: Float!
|
||||
"""
|
||||
Total request count human readable.
|
||||
"""
|
||||
totalRequestCountFormatted: String!
|
||||
}
|
||||
|
||||
type SchemaCheckConditionalBreakingChangeMetadata {
|
||||
period: DateRange!
|
||||
settings: SchemaCheckConditionalBreakingChangeMetadataSettings!
|
||||
usage: SchemaCheckConditionalBreakingChangeMetadataUsage!
|
||||
}
|
||||
|
||||
type SchemaCheckSuccess {
|
||||
valid: Boolean!
|
||||
initial: Boolean!
|
||||
|
|
@ -871,11 +967,14 @@ export default gql`
|
|||
safeSchemaChanges: SchemaChangeConnection
|
||||
schemaPolicyWarnings: SchemaPolicyWarningConnection
|
||||
schemaPolicyErrors: SchemaPolicyWarningConnection
|
||||
|
||||
"""
|
||||
Results of the contracts
|
||||
"""
|
||||
contractChecks: ContractCheckConnection
|
||||
"""
|
||||
Conditional breaking change metadata.
|
||||
"""
|
||||
conditionalBreakingChangeMetadata: SchemaCheckConditionalBreakingChangeMetadata
|
||||
}
|
||||
|
||||
"""
|
||||
|
|
@ -1022,6 +1121,10 @@ export default gql`
|
|||
Results of the contracts
|
||||
"""
|
||||
contractChecks: ContractCheckConnection
|
||||
"""
|
||||
Conditional breaking change metadata.
|
||||
"""
|
||||
conditionalBreakingChangeMetadata: SchemaCheckConditionalBreakingChangeMetadata
|
||||
|
||||
"""
|
||||
Whether the schema check was manually approved.
|
||||
|
|
@ -1095,6 +1198,10 @@ export default gql`
|
|||
Results of the contracts
|
||||
"""
|
||||
contractChecks: ContractCheckConnection
|
||||
"""
|
||||
Conditional breaking change metadata.
|
||||
"""
|
||||
conditionalBreakingChangeMetadata: SchemaCheckConditionalBreakingChangeMetadata
|
||||
|
||||
"""
|
||||
Whether this schema check can be approved manually.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { Injectable, Scope } from 'graphql-modules';
|
||||
import type { ConditionalBreakingChangeMetadata, SchemaChangeType } from '@hive/storage';
|
||||
import { formatNumber, formatPercentage } from '../lib/number-formatting';
|
||||
|
||||
/**
|
||||
* A class to avoid type mapping and drilling types by storing the data in a WeakMap instead...
|
||||
*/
|
||||
@Injectable({
|
||||
scope: Scope.Operation,
|
||||
})
|
||||
export class BreakingSchemaChangeUsageHelper {
|
||||
constructor() {}
|
||||
|
||||
private breakingSchemaChangeToUsageMap = new Map<
|
||||
string,
|
||||
ConditionalBreakingChangeMetadata['usage']
|
||||
>();
|
||||
|
||||
registerUsageDataForBreakingSchemaChange(
|
||||
schemaChange: SchemaChangeType,
|
||||
usage: ConditionalBreakingChangeMetadata['usage'],
|
||||
) {
|
||||
this.breakingSchemaChangeToUsageMap.set(schemaChange.id, usage);
|
||||
}
|
||||
|
||||
async getUsageDataForBreakingSchemaChange(schemaChange: SchemaChangeType) {
|
||||
if (schemaChange.usageStatistics === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usageData = this.breakingSchemaChangeToUsageMap.get(schemaChange.id);
|
||||
|
||||
if (usageData == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
topAffectedOperations: schemaChange.usageStatistics.topAffectedOperations.map(operation => {
|
||||
const percentage = (operation.count / usageData.totalRequestCount) * 100;
|
||||
return {
|
||||
...operation,
|
||||
countFormatted: formatNumber(operation.count),
|
||||
percentage,
|
||||
percentageFormatted: formatPercentage(percentage),
|
||||
};
|
||||
}),
|
||||
topAffectedClients: schemaChange.usageStatistics.topAffectedClients.map(client => {
|
||||
const percentage = (client.count / usageData.totalRequestCount) * 100;
|
||||
|
||||
return {
|
||||
...client,
|
||||
countFormatted: formatNumber(client.count),
|
||||
percentage,
|
||||
percentageFormatted: formatPercentage(percentage),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { TargetAccessScope } from '../../auth/providers/scopes';
|
|||
import { IdTranslator } from '../../shared/providers/id-translator';
|
||||
import { Logger } from '../../shared/providers/logger';
|
||||
import { Storage } from '../../shared/providers/storage';
|
||||
import { BreakingSchemaChangeUsageHelper } from './breaking-schema-changes-helper';
|
||||
import {
|
||||
Contracts,
|
||||
type Contract,
|
||||
|
|
@ -26,6 +27,7 @@ export class ContractsManager {
|
|||
private storage: Storage,
|
||||
private authManager: AuthManager,
|
||||
private idTranslator: IdTranslator,
|
||||
private breakingSchemaChangeUsageHelper: BreakingSchemaChangeUsageHelper,
|
||||
) {
|
||||
this.logger = logger.child({ service: 'ContractsManager' });
|
||||
}
|
||||
|
|
@ -220,9 +222,24 @@ export class ContractsManager {
|
|||
}
|
||||
|
||||
public async getContractsChecksForSchemaCheck(schemaCheck: SchemaCheck) {
|
||||
return this.contracts.getPaginatedContractChecksBySchemaCheckId({
|
||||
const contractChecks = await this.contracts.getPaginatedContractChecksBySchemaCheckId({
|
||||
schemaCheckId: schemaCheck.id,
|
||||
});
|
||||
|
||||
if (contractChecks?.edges && schemaCheck.conditionalBreakingChangeMetadata) {
|
||||
for (const edge of contractChecks.edges) {
|
||||
if (edge.node.breakingSchemaChanges) {
|
||||
for (const breakingSchemaChange of edge.node.breakingSchemaChanges) {
|
||||
this.breakingSchemaChangeUsageHelper.registerUsageDataForBreakingSchemaChange(
|
||||
breakingSchemaChange,
|
||||
schemaCheck.conditionalBreakingChangeMetadata.usage,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return contractChecks;
|
||||
}
|
||||
|
||||
public async getIsFirstComposableContractVersionForContractVersion(
|
||||
|
|
|
|||
|
|
@ -470,7 +470,7 @@ export class Contracts {
|
|||
, "breaking_schema_changes" = (
|
||||
SELECT json_agg(
|
||||
CASE
|
||||
WHEN COALESCE(jsonb_typeof("change"->'approvalMetadata'), 'null') = 'null'
|
||||
WHEN (COALESCE(jsonb_typeof("change"->'approvalMetadata'), 'null') = 'null' AND "change"->>'isSafeBasedOnUsage' = 'false')
|
||||
THEN jsonb_set("change", '{approvalMetadata}', ${sql.jsonb(
|
||||
args.approvalMetadata,
|
||||
)})
|
||||
|
|
@ -490,6 +490,10 @@ export class Contracts {
|
|||
|
||||
if (args.contextId !== null) {
|
||||
for (const change of contractCheck.breakingSchemaChanges ?? []) {
|
||||
if (change.isSafeBasedOnUsage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
breakingChangeApprovalInserts.push([
|
||||
contractCheck.contractId,
|
||||
args.contextId,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
import { isInputObjectType, isNonNullType, type GraphQLSchema } from 'graphql';
|
||||
import { type GraphQLSchema } from 'graphql';
|
||||
import { Injectable, Scope } from 'graphql-modules';
|
||||
import { Change, ChangeType, diff, DiffRule, UsageHandler } from '@graphql-inspector/core';
|
||||
import { Change, ChangeType, diff } from '@graphql-inspector/core';
|
||||
import { HiveSchemaChangeModel, SchemaChangeType } from '@hive/storage';
|
||||
import type * as Types from '../../../__generated__/types';
|
||||
import type { TargetSettings } from '../../../shared/entities';
|
||||
import { createPeriod } from '../../../shared/helpers';
|
||||
import { sentry } from '../../../shared/sentry';
|
||||
import { OperationsReader } from '../../operations/providers/operations-reader';
|
||||
import { Logger } from '../../shared/providers/logger';
|
||||
import { Storage } from '../../shared/providers/storage';
|
||||
|
||||
@Injectable({
|
||||
scope: Scope.Singleton,
|
||||
|
|
@ -17,37 +12,15 @@ import { Storage } from '../../shared/providers/storage';
|
|||
export class Inspector {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
logger: Logger,
|
||||
private operationsReader: OperationsReader,
|
||||
private storage: Storage,
|
||||
) {
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger.child({ service: 'Inspector' });
|
||||
}
|
||||
|
||||
@sentry('Inspector.diff')
|
||||
async diff(
|
||||
existing: GraphQLSchema,
|
||||
incoming: GraphQLSchema,
|
||||
/** If provided, the breaking changes will be enhanced with isSafeBasedOnUsage, */
|
||||
selector?: Types.TargetSelector,
|
||||
): Promise<Array<SchemaChangeType>> {
|
||||
async diff(existing: GraphQLSchema, incoming: GraphQLSchema): Promise<Array<SchemaChangeType>> {
|
||||
this.logger.debug('Comparing Schemas');
|
||||
|
||||
const settings = selector
|
||||
? await this.getConditionalBreakingChangeSettings({ selector })
|
||||
: null;
|
||||
|
||||
const changes = await diff(
|
||||
existing,
|
||||
incoming,
|
||||
settings ? [DiffRule.considerUsage] : undefined,
|
||||
settings
|
||||
? {
|
||||
checkUsage: this.getCheckUsageForSettings({ incoming, existing, settings }),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const changes = await diff(existing, incoming);
|
||||
|
||||
return changes
|
||||
.filter(dropTrimmedDescriptionChangedChange)
|
||||
|
|
@ -60,147 +33,6 @@ export class Inspector {
|
|||
)
|
||||
.sort((a, b) => a.criticality.localeCompare(b.criticality));
|
||||
}
|
||||
|
||||
private getCheckUsageForSettings(args: {
|
||||
incoming: GraphQLSchema;
|
||||
existing: GraphQLSchema;
|
||||
settings: {
|
||||
period: number;
|
||||
percentage: number;
|
||||
targets: readonly string[];
|
||||
excludedClients: readonly string[];
|
||||
};
|
||||
}): UsageHandler {
|
||||
return async fields => {
|
||||
this.logger.debug('Checking usage (fields=%s)', fields.length);
|
||||
const BREAKING = false;
|
||||
const NOT_BREAKING = true;
|
||||
const allUsed = fields.map(() => BREAKING);
|
||||
|
||||
if (fields.length === 0) {
|
||||
this.logger.debug('Mark all as used');
|
||||
return allUsed;
|
||||
}
|
||||
|
||||
this.logger.debug('Usage validation enabled');
|
||||
|
||||
const fixedFields = fields.map(({ type, field, argument, meta }) => {
|
||||
if (type && field) {
|
||||
const typeDefinition = args.incoming.getType(type) || args.existing.getType(type);
|
||||
const change: Change<ChangeType> = meta.change;
|
||||
|
||||
if (typeDefinition && isInputObjectType(typeDefinition)) {
|
||||
const typeBefore = args.existing.getType(type);
|
||||
const typeAfter = args.incoming.getType(type);
|
||||
|
||||
if (isInputObjectType(typeBefore) && isInputObjectType(typeAfter)) {
|
||||
const fieldAfter = typeAfter.getFields()[field];
|
||||
// Adding a non-nullable input field to a used input object type is a breaking change.
|
||||
// That's why we need to check if the input type is used, not the field itself (as it's new)
|
||||
if (change.type === ChangeType.InputFieldAdded && isNonNullType(fieldAfter.type)) {
|
||||
return {
|
||||
type,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
field,
|
||||
argument,
|
||||
};
|
||||
});
|
||||
|
||||
const statsList = await this.getSchemaCoordinateStatistics({
|
||||
settings: args.settings,
|
||||
fields: fixedFields,
|
||||
});
|
||||
|
||||
if (!statsList) {
|
||||
return allUsed;
|
||||
}
|
||||
|
||||
this.logger.debug('Got the stats');
|
||||
|
||||
return fixedFields.map(function useStats({
|
||||
type,
|
||||
field,
|
||||
argument,
|
||||
}: {
|
||||
type: string;
|
||||
field?: string;
|
||||
argument?: string;
|
||||
}) {
|
||||
const stats = statsList.find(
|
||||
s => s.field === field && s.type === type && s.argument === argument,
|
||||
);
|
||||
|
||||
if (!stats) {
|
||||
return NOT_BREAKING;
|
||||
}
|
||||
|
||||
const aboveThreshold = stats.percentage > args.settings.percentage;
|
||||
return aboveThreshold ? BREAKING : NOT_BREAKING;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private async getConditionalBreakingChangeSettings({
|
||||
selector,
|
||||
}: {
|
||||
selector: Types.TargetSelector;
|
||||
}) {
|
||||
try {
|
||||
const settings = await this.storage.getTargetSettings(selector);
|
||||
|
||||
if (!settings.validation.enabled) {
|
||||
this.logger.debug('Usage validation disabled');
|
||||
this.logger.debug('Mark all as used');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (settings.validation.enabled && settings.validation.targets.length === 0) {
|
||||
this.logger.debug('Usage validation enabled but no targets to check against');
|
||||
this.logger.debug('Mark all as used');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
period: settings.validation.period,
|
||||
percentage: settings.validation.percentage,
|
||||
targets: settings.validation.targets,
|
||||
excludedClients: settings.validation.excludedClients,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to get settings`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getSchemaCoordinateStatistics({
|
||||
fields,
|
||||
settings,
|
||||
}: {
|
||||
settings: Omit<TargetSettings['validation'], 'enabled'>;
|
||||
fields: ReadonlyArray<{
|
||||
type: string;
|
||||
field?: string | null;
|
||||
argument?: string | null;
|
||||
}>;
|
||||
}) {
|
||||
try {
|
||||
return await this.operationsReader.readFieldListStats({
|
||||
fields,
|
||||
period: createPeriod(`${settings.period}d`),
|
||||
targetIds: settings.targets,
|
||||
excludedClients: settings.excludedClients,
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to read stats`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Injectable, Scope } from 'graphql-modules';
|
||||
import { FederationOrchestrator } from '../orchestrators/federation';
|
||||
import { StitchingOrchestrator } from '../orchestrators/stitching';
|
||||
import { RegistryChecks } from '../registry-checks';
|
||||
import { RegistryChecks, type ConditionalBreakingChangeDiffConfig } from '../registry-checks';
|
||||
import { swapServices } from '../schema-helper';
|
||||
import type { PublishInput } from '../schema-publisher';
|
||||
import type {
|
||||
|
|
@ -42,6 +42,7 @@ export class CompositeLegacyModel {
|
|||
project,
|
||||
organization,
|
||||
baseSchema,
|
||||
conditionalBreakingChangeDiffConfig,
|
||||
}: {
|
||||
input: {
|
||||
sdl: string;
|
||||
|
|
@ -60,6 +61,7 @@ export class CompositeLegacyModel {
|
|||
baseSchema: string | null;
|
||||
project: Project;
|
||||
organization: Organization;
|
||||
conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
|
||||
}): Promise<SchemaCheckResult> {
|
||||
const incoming: PushedCompositeSchema = {
|
||||
kind: 'composite',
|
||||
|
|
@ -118,13 +120,13 @@ export class CompositeLegacyModel {
|
|||
});
|
||||
|
||||
const diffCheck = await this.checks.diff({
|
||||
usageDataSelector: selector,
|
||||
includeUrlChanges: false,
|
||||
filterOutFederationChanges: project.type === ProjectType.FEDERATION,
|
||||
approvedChanges: null,
|
||||
existingSdl: previousVersionSdl,
|
||||
incomingSdl:
|
||||
compositionCheck.result?.fullSchemaSdl ?? compositionCheck.reason?.fullSchemaSdl ?? null,
|
||||
conditionalBreakingChangeConfig: conditionalBreakingChangeDiffConfig,
|
||||
});
|
||||
|
||||
if (compositionCheck.status === 'failed' || diffCheck.status === 'failed') {
|
||||
|
|
@ -160,6 +162,7 @@ export class CompositeLegacyModel {
|
|||
project,
|
||||
organization,
|
||||
baseSchema,
|
||||
conditionalBreakingChangeDiffConfig,
|
||||
}: {
|
||||
input: PublishInput;
|
||||
project: Project;
|
||||
|
|
@ -171,6 +174,7 @@ export class CompositeLegacyModel {
|
|||
schemas: PushedCompositeSchema[];
|
||||
} | null;
|
||||
baseSchema: string | null;
|
||||
conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
|
||||
}): Promise<SchemaPublishResult> {
|
||||
const incoming: PushedCompositeSchema = {
|
||||
kind: 'composite',
|
||||
|
|
@ -277,11 +281,6 @@ export class CompositeLegacyModel {
|
|||
|
||||
const [diffCheck, metadataCheck] = await Promise.all([
|
||||
this.checks.diff({
|
||||
usageDataSelector: {
|
||||
target: target.id,
|
||||
project: project.id,
|
||||
organization: project.orgId,
|
||||
},
|
||||
includeUrlChanges: {
|
||||
schemasBefore: latestVersion?.schemas ?? [],
|
||||
schemasAfter: schemas,
|
||||
|
|
@ -290,6 +289,7 @@ export class CompositeLegacyModel {
|
|||
approvedChanges: null,
|
||||
existingSdl: previousVersionSdl,
|
||||
incomingSdl: compositionCheck.result?.fullSchemaSdl ?? null,
|
||||
conditionalBreakingChangeConfig: conditionalBreakingChangeDiffConfig,
|
||||
}),
|
||||
isFederation
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Injectable, Scope } from 'graphql-modules';
|
|||
import { SchemaChangeType } from '@hive/storage';
|
||||
import { FederationOrchestrator } from '../orchestrators/federation';
|
||||
import { StitchingOrchestrator } from '../orchestrators/stitching';
|
||||
import { RegistryChecks } from '../registry-checks';
|
||||
import { RegistryChecks, type ConditionalBreakingChangeDiffConfig } from '../registry-checks';
|
||||
import { swapServices } from '../schema-helper';
|
||||
import type { PublishInput } from '../schema-publisher';
|
||||
import type {
|
||||
|
|
@ -48,11 +48,7 @@ export class CompositeModel {
|
|||
}
|
||||
> | null;
|
||||
compositionCheck: Awaited<ReturnType<RegistryChecks['composition']>>;
|
||||
usageDataSelector: {
|
||||
organization: string;
|
||||
project: string;
|
||||
target: string;
|
||||
};
|
||||
conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
|
||||
}): Promise<Array<ContractCheckInput> | null> {
|
||||
const contractResults = (args.compositionCheck.result ?? args.compositionCheck.reason)
|
||||
?.contracts;
|
||||
|
|
@ -73,7 +69,7 @@ export class CompositeModel {
|
|||
contractName: contract.contract.contractName,
|
||||
compositionCheck: contractCompositionResult,
|
||||
diffCheck: await this.checks.diff({
|
||||
usageDataSelector: args.usageDataSelector,
|
||||
conditionalBreakingChangeConfig: args.conditionalBreakingChangeDiffConfig,
|
||||
includeUrlChanges: false,
|
||||
// contracts were introduced after this, so we do not need to filter out federation.
|
||||
filterOutFederationChanges: false,
|
||||
|
|
@ -96,6 +92,7 @@ export class CompositeModel {
|
|||
organization,
|
||||
baseSchema,
|
||||
approvedChanges,
|
||||
conditionalBreakingChangeDiffConfig,
|
||||
contracts,
|
||||
}: {
|
||||
input: {
|
||||
|
|
@ -122,6 +119,7 @@ export class CompositeModel {
|
|||
project: Project;
|
||||
organization: Organization;
|
||||
approvedChanges: Map<string, SchemaChangeType>;
|
||||
conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
|
||||
contracts: Array<
|
||||
ContractInput & {
|
||||
approvedChanges: Map<string, SchemaChangeType> | null;
|
||||
|
|
@ -203,18 +201,18 @@ export class CompositeModel {
|
|||
const contractChecks = await this.getContractChecks({
|
||||
contracts,
|
||||
compositionCheck,
|
||||
usageDataSelector: selector,
|
||||
conditionalBreakingChangeDiffConfig,
|
||||
});
|
||||
|
||||
const [diffCheck, policyCheck] = await Promise.all([
|
||||
this.checks.diff({
|
||||
usageDataSelector: selector,
|
||||
includeUrlChanges: false,
|
||||
filterOutFederationChanges: project.type === ProjectType.FEDERATION,
|
||||
approvedChanges,
|
||||
existingSdl: previousVersionSdl,
|
||||
incomingSdl:
|
||||
compositionCheck.result?.fullSchemaSdl ?? compositionCheck.reason?.fullSchemaSdl ?? null,
|
||||
conditionalBreakingChangeConfig: conditionalBreakingChangeDiffConfig,
|
||||
}),
|
||||
this.checks.policyCheck({
|
||||
selector,
|
||||
|
|
@ -281,6 +279,7 @@ export class CompositeModel {
|
|||
schemaVersionContractNames,
|
||||
baseSchema,
|
||||
contracts,
|
||||
conditionalBreakingChangeDiffConfig,
|
||||
}: {
|
||||
input: PublishInput;
|
||||
project: Project;
|
||||
|
|
@ -299,6 +298,7 @@ export class CompositeModel {
|
|||
schemaVersionContractNames: string[] | null;
|
||||
baseSchema: string | null;
|
||||
contracts: Array<ContractInput> | null;
|
||||
conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
|
||||
}): Promise<SchemaPublishResult> {
|
||||
const incoming: PushedCompositeSchema = {
|
||||
kind: 'composite',
|
||||
|
|
@ -439,11 +439,7 @@ export class CompositeModel {
|
|||
});
|
||||
|
||||
const diffCheck = await this.checks.diff({
|
||||
usageDataSelector: {
|
||||
target: target.id,
|
||||
project: project.id,
|
||||
organization: project.orgId,
|
||||
},
|
||||
conditionalBreakingChangeConfig: conditionalBreakingChangeDiffConfig,
|
||||
includeUrlChanges: {
|
||||
schemasBefore: schemaVersionToCompareAgainst?.schemas ?? [],
|
||||
schemasAfter: schemas,
|
||||
|
|
@ -454,16 +450,10 @@ export class CompositeModel {
|
|||
incomingSdl: compositionCheck.result?.fullSchemaSdl ?? null,
|
||||
});
|
||||
|
||||
const usageDataSelector = {
|
||||
target: target.id,
|
||||
project: project.id,
|
||||
organization: project.orgId,
|
||||
};
|
||||
|
||||
const contractChecks = await this.getContractChecks({
|
||||
contracts,
|
||||
compositionCheck,
|
||||
usageDataSelector,
|
||||
conditionalBreakingChangeDiffConfig,
|
||||
});
|
||||
|
||||
const hasNewUrl =
|
||||
|
|
@ -521,6 +511,7 @@ export class CompositeModel {
|
|||
project,
|
||||
selector,
|
||||
baseSchema,
|
||||
conditionalBreakingChangeDiffConfig,
|
||||
contracts,
|
||||
}: {
|
||||
input: {
|
||||
|
|
@ -545,6 +536,7 @@ export class CompositeModel {
|
|||
schemas: PushedCompositeSchema[];
|
||||
} | null;
|
||||
contracts: Array<ContractInput> | null;
|
||||
conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
|
||||
}): Promise<SchemaDeleteResult> {
|
||||
const incoming: DeletedCompositeSchema = {
|
||||
kind: 'composite',
|
||||
|
|
@ -606,7 +598,7 @@ export class CompositeModel {
|
|||
});
|
||||
|
||||
const diffCheck = await this.checks.diff({
|
||||
usageDataSelector: selector,
|
||||
conditionalBreakingChangeConfig: conditionalBreakingChangeDiffConfig,
|
||||
includeUrlChanges: {
|
||||
schemasBefore: latestVersion.schemas,
|
||||
schemasAfter: schemas,
|
||||
|
|
@ -620,7 +612,7 @@ export class CompositeModel {
|
|||
const contractChecks = await this.getContractChecks({
|
||||
contracts,
|
||||
compositionCheck,
|
||||
usageDataSelector: selector,
|
||||
conditionalBreakingChangeDiffConfig,
|
||||
});
|
||||
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Injectable, Scope } from 'graphql-modules';
|
||||
import { SingleOrchestrator } from '../orchestrators/single';
|
||||
import { RegistryChecks } from '../registry-checks';
|
||||
import { ConditionalBreakingChangeDiffConfig, RegistryChecks } from '../registry-checks';
|
||||
import type { PublishInput } from '../schema-publisher';
|
||||
import type { Organization, Project, SingleSchema, Target } from './../../../../shared/entities';
|
||||
import { Logger } from './../../../shared/providers/logger';
|
||||
|
|
@ -33,6 +33,7 @@ export class SingleLegacyModel {
|
|||
project,
|
||||
organization,
|
||||
baseSchema,
|
||||
conditionalBreakingChangeDiffConfig,
|
||||
}: {
|
||||
input: {
|
||||
sdl: string;
|
||||
|
|
@ -50,6 +51,7 @@ export class SingleLegacyModel {
|
|||
baseSchema: string | null;
|
||||
project: Project;
|
||||
organization: Organization;
|
||||
conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
|
||||
}): Promise<SchemaCheckResult> {
|
||||
const incoming: SingleSchema = {
|
||||
kind: 'single',
|
||||
|
|
@ -102,7 +104,7 @@ export class SingleLegacyModel {
|
|||
});
|
||||
|
||||
const diffCheck = await this.checks.diff({
|
||||
usageDataSelector: selector,
|
||||
conditionalBreakingChangeConfig: conditionalBreakingChangeDiffConfig,
|
||||
includeUrlChanges: false,
|
||||
filterOutFederationChanges: false,
|
||||
approvedChanges: null,
|
||||
|
|
@ -143,6 +145,7 @@ export class SingleLegacyModel {
|
|||
project,
|
||||
organization,
|
||||
baseSchema,
|
||||
conditionalBreakingChangeDiffConfig,
|
||||
}: {
|
||||
input: PublishInput;
|
||||
project: Project;
|
||||
|
|
@ -154,6 +157,7 @@ export class SingleLegacyModel {
|
|||
schemas: [SingleSchema];
|
||||
} | null;
|
||||
baseSchema: string | null;
|
||||
conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
|
||||
}): Promise<SchemaPublishResult> {
|
||||
const incoming: SingleSchema = {
|
||||
kind: 'single',
|
||||
|
|
@ -217,11 +221,7 @@ export class SingleLegacyModel {
|
|||
|
||||
const [diffCheck, metadataCheck] = await Promise.all([
|
||||
this.checks.diff({
|
||||
usageDataSelector: {
|
||||
target: target.id,
|
||||
project: project.id,
|
||||
organization: project.orgId,
|
||||
},
|
||||
conditionalBreakingChangeConfig: conditionalBreakingChangeDiffConfig,
|
||||
includeUrlChanges: false,
|
||||
filterOutFederationChanges: false,
|
||||
approvedChanges: null,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Injectable, Scope } from 'graphql-modules';
|
||||
import { SchemaChangeType } from '@hive/storage';
|
||||
import { SingleOrchestrator } from '../orchestrators/single';
|
||||
import { RegistryChecks } from '../registry-checks';
|
||||
import { ConditionalBreakingChangeDiffConfig, RegistryChecks } from '../registry-checks';
|
||||
import type { PublishInput } from '../schema-publisher';
|
||||
import type { Organization, Project, SingleSchema, Target } from './../../../../shared/entities';
|
||||
import { Logger } from './../../../shared/providers/logger';
|
||||
|
|
@ -35,6 +35,7 @@ export class SingleModel {
|
|||
organization,
|
||||
baseSchema,
|
||||
approvedChanges,
|
||||
conditionalBreakingChangeDiffConfig,
|
||||
}: {
|
||||
input: {
|
||||
sdl: string;
|
||||
|
|
@ -58,6 +59,7 @@ export class SingleModel {
|
|||
project: Project;
|
||||
organization: Organization;
|
||||
approvedChanges: Map<string, SchemaChangeType>;
|
||||
conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
|
||||
}): Promise<SchemaCheckResult> {
|
||||
const incoming: SingleSchema = {
|
||||
kind: 'single',
|
||||
|
|
@ -114,7 +116,7 @@ export class SingleModel {
|
|||
|
||||
const [diffCheck, policyCheck] = await Promise.all([
|
||||
this.checks.diff({
|
||||
usageDataSelector: selector,
|
||||
conditionalBreakingChangeConfig: conditionalBreakingChangeDiffConfig,
|
||||
includeUrlChanges: false,
|
||||
filterOutFederationChanges: false,
|
||||
approvedChanges,
|
||||
|
|
@ -166,6 +168,7 @@ export class SingleModel {
|
|||
latest,
|
||||
latestComposable,
|
||||
baseSchema,
|
||||
conditionalBreakingChangeDiffConfig,
|
||||
}: {
|
||||
input: PublishInput;
|
||||
organization: Organization;
|
||||
|
|
@ -182,6 +185,7 @@ export class SingleModel {
|
|||
schemas: [SingleSchema];
|
||||
} | null;
|
||||
baseSchema: string | null;
|
||||
conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
|
||||
}): Promise<SchemaPublishResult> {
|
||||
const incoming: SingleSchema = {
|
||||
kind: 'single',
|
||||
|
|
@ -247,11 +251,7 @@ export class SingleModel {
|
|||
const [metadataCheck, diffCheck] = await Promise.all([
|
||||
this.checks.metadata(incoming, latestVersion ? latestVersion.schemas[0] : null),
|
||||
this.checks.diff({
|
||||
usageDataSelector: {
|
||||
target: target.id,
|
||||
project: project.id,
|
||||
organization: project.orgId,
|
||||
},
|
||||
conditionalBreakingChangeConfig: conditionalBreakingChangeDiffConfig,
|
||||
filterOutFederationChanges: false,
|
||||
includeUrlChanges: false,
|
||||
approvedChanges: null,
|
||||
|
|
|
|||
|
|
@ -7,14 +7,16 @@ import type { CheckPolicyResponse } from '@hive/policy';
|
|||
import type { CompositionFailureError, ContractsInputType } from '@hive/schema';
|
||||
import {
|
||||
HiveSchemaChangeModel,
|
||||
SchemaChangeType,
|
||||
type RegistryServiceUrlChangeSerializableChange,
|
||||
type SchemaChangeType,
|
||||
} from '@hive/storage';
|
||||
import { ProjectType } from '../../../shared/entities';
|
||||
import { buildSortedSchemaFromSchemaObject } from '../../../shared/schema';
|
||||
import { OperationsReader } from '../../operations/providers/operations-reader';
|
||||
import { SchemaPolicyProvider } from '../../policy/providers/schema-policy.provider';
|
||||
import type {
|
||||
ComposeAndValidateResult,
|
||||
DateRange,
|
||||
Orchestrator,
|
||||
Organization,
|
||||
Project,
|
||||
|
|
@ -26,6 +28,13 @@ import { Inspector } from './inspector';
|
|||
import { SchemaCheckWarning } from './models/shared';
|
||||
import { extendWithBase, isCompositeSchema, SchemaHelper } from './schema-helper';
|
||||
|
||||
export type ConditionalBreakingChangeDiffConfig = {
|
||||
period: DateRange;
|
||||
requestCountThreshold: number;
|
||||
targetIds: string[];
|
||||
excludedClientNames: string[] | null;
|
||||
};
|
||||
|
||||
// The reason why I'm using `result` and `reason` instead of just `data` for both:
|
||||
// https://bit.ly/hive-check-result-data
|
||||
export type CheckResult<C = unknown, F = unknown> =
|
||||
|
|
@ -156,6 +165,7 @@ export class RegistryChecks {
|
|||
private policy: SchemaPolicyProvider,
|
||||
private inspector: Inspector,
|
||||
private logger: Logger,
|
||||
private operationsReader: OperationsReader,
|
||||
) {}
|
||||
|
||||
async checksum(args: {
|
||||
|
|
@ -392,12 +402,8 @@ export class RegistryChecks {
|
|||
filterOutFederationChanges: boolean;
|
||||
/** Lookup map of changes that are approved and thus safe. */
|
||||
approvedChanges: null | Map<string, SchemaChangeType>;
|
||||
/** Selector for fetching conditional breaking changes. */
|
||||
usageDataSelector: null | {
|
||||
organization: string;
|
||||
project: string;
|
||||
target: string;
|
||||
};
|
||||
/** Settings for fetching conditional breaking changes. */
|
||||
conditionalBreakingChangeConfig: null | ConditionalBreakingChangeDiffConfig;
|
||||
}) {
|
||||
if (args.existingSdl == null || args.incomingSdl == null) {
|
||||
this.logger.debug('Skip diff check due to either existing or incoming SDL being absent.');
|
||||
|
|
@ -428,11 +434,62 @@ export class RegistryChecks {
|
|||
} satisfies CheckResult;
|
||||
}
|
||||
|
||||
let inspectorChanges = await this.inspector.diff(
|
||||
existingSchema,
|
||||
incomingSchema,
|
||||
args.usageDataSelector ?? undefined,
|
||||
);
|
||||
let inspectorChanges = await this.inspector.diff(existingSchema, incomingSchema);
|
||||
|
||||
// Filter out federation specific changes as they are not relevant for the schema diff and were in previous schema versions by accident.
|
||||
if (args.filterOutFederationChanges === true) {
|
||||
inspectorChanges = inspectorChanges.filter(change => !isFederationRelatedChange(change));
|
||||
}
|
||||
|
||||
if (args.conditionalBreakingChangeConfig) {
|
||||
this.logger.debug('Conditional breaking change settings available.');
|
||||
const settings = args.conditionalBreakingChangeConfig;
|
||||
|
||||
this.logger.debug('Fetching affected operations and affected clients for breaking changes.');
|
||||
|
||||
await Promise.all(
|
||||
inspectorChanges.map(async change => {
|
||||
if (
|
||||
change.criticality !== CriticalityLevel.Breaking ||
|
||||
!change.breakingChangeSchemaCoordinate
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We need to run both the affected operations an affected clients query.
|
||||
// Since the affected clients query is lighter it makes more sense to run it first and skip running the operations query if no clients are affected, as it will also yield zero results in that case.
|
||||
|
||||
const topAffectedClients = await this.operationsReader.getTopClientsForSchemaCoordinate({
|
||||
targetIds: settings.targetIds,
|
||||
excludedClients: settings.excludedClientNames,
|
||||
period: settings.period,
|
||||
schemaCoordinate: change.breakingChangeSchemaCoordinate,
|
||||
});
|
||||
|
||||
if (topAffectedClients) {
|
||||
const topAffectedOperations =
|
||||
await this.operationsReader.getTopOperationsForSchemaCoordinate({
|
||||
targetIds: settings.targetIds,
|
||||
excludedClients: settings.excludedClientNames,
|
||||
period: settings.period,
|
||||
requestCountThreshold: settings.requestCountThreshold,
|
||||
schemaCoordinate: change.breakingChangeSchemaCoordinate,
|
||||
});
|
||||
|
||||
if (topAffectedOperations) {
|
||||
change.usageStatistics = {
|
||||
topAffectedOperations,
|
||||
topAffectedClients,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
change.isSafeBasedOnUsage = change.usageStatistics === null;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.logger.debug('No conditional breaking change settings available');
|
||||
}
|
||||
|
||||
if (args.includeUrlChanges) {
|
||||
inspectorChanges.push(
|
||||
|
|
@ -443,24 +500,26 @@ export class RegistryChecks {
|
|||
);
|
||||
}
|
||||
|
||||
// Filter out federation specific changes as they are not relevant for the schema diff and were in previous schema versions by accident.
|
||||
if (args.filterOutFederationChanges === true) {
|
||||
inspectorChanges = inspectorChanges.filter(change => !isFederationRelatedChange(change));
|
||||
}
|
||||
|
||||
let isFailure = false;
|
||||
const safeChanges: Array<SchemaChangeType> = [];
|
||||
const breakingChanges: Array<SchemaChangeType> = [];
|
||||
|
||||
for (const change of inspectorChanges) {
|
||||
if (change.isSafeBasedOnUsage === true) {
|
||||
breakingChanges.push(change);
|
||||
} else if (change.criticality === CriticalityLevel.Breaking) {
|
||||
if (change.criticality === CriticalityLevel.Breaking) {
|
||||
if (change.isSafeBasedOnUsage === true) {
|
||||
breakingChanges.push(change);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If this change is approved, we return the already approved on instead of the newly detected one,
|
||||
// as it it contains the necessary metadata on when the change got first approved and by whom.
|
||||
const approvedChange = args.approvedChanges?.get(change.id);
|
||||
if (approvedChange) {
|
||||
breakingChanges.push(approvedChange);
|
||||
breakingChanges.push({
|
||||
...approvedChange,
|
||||
isSafeBasedOnUsage: change.isSafeBasedOnUsage,
|
||||
usageStatistics: change.usageStatistics,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
isFailure = true;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Injectable, Scope } from 'graphql-modules';
|
||||
import { FailedSchemaCheckMapper, SuccessfulSchemaCheckMapper } from '../../../shared/mappers';
|
||||
import { Storage } from '../../shared/providers/storage';
|
||||
import { formatNumber } from '../lib/number-formatting';
|
||||
import { SchemaManager } from './schema-manager';
|
||||
|
||||
type SchemaCheck = FailedSchemaCheckMapper | SuccessfulSchemaCheckMapper;
|
||||
|
|
@ -71,4 +72,30 @@ export class SchemaCheckManager {
|
|||
|
||||
return service?.sdl ?? null;
|
||||
}
|
||||
|
||||
getConditionalBreakingChangeMetadata(schemaCheck: SchemaCheck) {
|
||||
if (!schemaCheck.conditionalBreakingChangeMetadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
period: {
|
||||
from: schemaCheck.conditionalBreakingChangeMetadata.period.from.toISOString(),
|
||||
to: schemaCheck.conditionalBreakingChangeMetadata.period.to.toISOString(),
|
||||
},
|
||||
settings: {
|
||||
percentage: schemaCheck.conditionalBreakingChangeMetadata.settings.percentage,
|
||||
retentionInDays: schemaCheck.conditionalBreakingChangeMetadata.settings.retentionInDays,
|
||||
excludedClientNames:
|
||||
schemaCheck.conditionalBreakingChangeMetadata.settings.excludedClientNames,
|
||||
targets: schemaCheck.conditionalBreakingChangeMetadata.settings.targets,
|
||||
},
|
||||
usage: {
|
||||
totalRequestCount: schemaCheck.conditionalBreakingChangeMetadata.usage.totalRequestCount,
|
||||
totalRequestCountFormatted: formatNumber(
|
||||
schemaCheck.conditionalBreakingChangeMetadata.usage.totalRequestCount,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import { parse, print } from 'graphql';
|
|||
import { Inject, Injectable, Scope } from 'graphql-modules';
|
||||
import lodash from 'lodash';
|
||||
import { z } from 'zod';
|
||||
import type { SchemaChangeType, SchemaCheck, SchemaCompositionError } from '@hive/storage';
|
||||
import type {
|
||||
ConditionalBreakingChangeMetadata,
|
||||
SchemaChangeType,
|
||||
SchemaCheck,
|
||||
SchemaCompositionError,
|
||||
} from '@hive/storage';
|
||||
import { sortSDL } from '@theguild/federation-composition';
|
||||
import { RegistryModel, SchemaChecksFilter } from '../../../__generated__/types';
|
||||
import {
|
||||
|
|
@ -32,6 +37,7 @@ import {
|
|||
TargetSelector,
|
||||
} from '../../shared/providers/storage';
|
||||
import { TargetManager } from '../../target/providers/target-manager';
|
||||
import { BreakingSchemaChangeUsageHelper } from './breaking-schema-changes-helper';
|
||||
import { SCHEMA_MODULE_CONFIG, type SchemaModuleConfig } from './config';
|
||||
import { Contracts } from './contracts';
|
||||
import { FederationOrchestrator } from './orchestrators/federation';
|
||||
|
|
@ -76,6 +82,7 @@ export class SchemaManager {
|
|||
private organizationManager: OrganizationManager,
|
||||
private schemaHelper: SchemaHelper,
|
||||
private contracts: Contracts,
|
||||
private breakingSchemaChangeUsageHelper: BreakingSchemaChangeUsageHelper,
|
||||
@Inject(SCHEMA_MODULE_CONFIG) private schemaModuleConfig: SchemaModuleConfig,
|
||||
) {
|
||||
this.logger = logger.child({ source: 'SchemaManager' });
|
||||
|
|
@ -347,6 +354,7 @@ export class SchemaManager {
|
|||
schemaCompositionErrors: Array<SchemaCompositionError> | null;
|
||||
changes: null | Array<SchemaChangeType>;
|
||||
}>;
|
||||
conditionalBreakingChangeMetadata: null | ConditionalBreakingChangeMetadata;
|
||||
} & TargetSelector) &
|
||||
(
|
||||
| {
|
||||
|
|
@ -678,6 +686,15 @@ export class SchemaManager {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (schemaCheck.breakingSchemaChanges && schemaCheck.conditionalBreakingChangeMetadata) {
|
||||
for (const change of schemaCheck.breakingSchemaChanges) {
|
||||
this.breakingSchemaChangeUsageHelper.registerUsageDataForBreakingSchemaChange(
|
||||
change,
|
||||
schemaCheck.conditionalBreakingChangeMetadata.usage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return schemaCheck;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,16 @@ import lodash from 'lodash';
|
|||
import promClient from 'prom-client';
|
||||
import { z } from 'zod';
|
||||
import { CriticalityLevel } from '@graphql-inspector/core';
|
||||
import { SchemaChangeType, SchemaCheck } from '@hive/storage';
|
||||
import type {
|
||||
ConditionalBreakingChangeMetadata,
|
||||
SchemaChangeType,
|
||||
SchemaCheck,
|
||||
} 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 { HiveError } from '../../../shared/errors';
|
||||
import { createPeriod } from '../../../shared/helpers';
|
||||
import { isGitHubRepositoryString } from '../../../shared/is-github-repository-string';
|
||||
import { bolderize } from '../../../shared/markdown';
|
||||
import { sentry } from '../../../shared/sentry';
|
||||
|
|
@ -21,6 +26,7 @@ import {
|
|||
GitHubIntegrationManager,
|
||||
type GitHubCheckRun,
|
||||
} from '../../integrations/providers/github-integration-manager';
|
||||
import { OperationsReader } from '../../operations/providers/operations-reader';
|
||||
import { OrganizationManager } from '../../organization/providers/organization-manager';
|
||||
import { ProjectManager } from '../../project/providers/project-manager';
|
||||
import { RateLimitProvider } from '../../rate-limit/providers/rate-limit.provider';
|
||||
|
|
@ -50,6 +56,7 @@ import {
|
|||
} from './models/shared';
|
||||
import { SingleModel } from './models/single';
|
||||
import { SingleLegacyModel } from './models/single-legacy';
|
||||
import type { ConditionalBreakingChangeDiffConfig } from './registry-checks';
|
||||
import { ensureCompositeSchemas, ensureSingleSchema, SchemaHelper } from './schema-helper';
|
||||
import { SchemaManager } from './schema-manager';
|
||||
import { SchemaVersionHelper } from './schema-version-helper';
|
||||
|
|
@ -101,6 +108,13 @@ function assertNonNull<T>(value: T | null, message: string): T {
|
|||
return value;
|
||||
}
|
||||
|
||||
type ConditionalBreakingChangeConfiguration = {
|
||||
conditionalBreakingChangeDiffConfig: ConditionalBreakingChangeDiffConfig;
|
||||
retentionInDays: number;
|
||||
percentage: number;
|
||||
totalRequestCount: number;
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
scope: Scope.Operation,
|
||||
})
|
||||
|
|
@ -138,6 +152,7 @@ export class SchemaPublisher {
|
|||
private rateLimit: RateLimitProvider,
|
||||
private contracts: Contracts,
|
||||
private schemaVersionHelper: SchemaVersionHelper,
|
||||
private operationsReader: OperationsReader,
|
||||
@Inject(SCHEMA_MODULE_CONFIG) private schemaModuleConfig: SchemaModuleConfig,
|
||||
singleModel: SingleModel,
|
||||
compositeModel: CompositeModel,
|
||||
|
|
@ -161,6 +176,100 @@ export class SchemaPublisher {
|
|||
};
|
||||
}
|
||||
|
||||
private async getConditionalBreakingChangeConfiguration({
|
||||
selector,
|
||||
}: {
|
||||
selector: {
|
||||
organization: string;
|
||||
project: string;
|
||||
target: string;
|
||||
};
|
||||
}): Promise<ConditionalBreakingChangeConfiguration | null> {
|
||||
try {
|
||||
const settings = await this.storage.getTargetSettings(selector);
|
||||
|
||||
if (!settings.validation.enabled) {
|
||||
this.logger.debug('Usage validation disabled');
|
||||
this.logger.debug('Mark all as used');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (settings.validation.enabled && settings.validation.targets.length === 0) {
|
||||
this.logger.debug('Usage validation enabled but no targets to check against');
|
||||
this.logger.debug('Mark all as used');
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetIds = settings.validation.targets;
|
||||
const excludedClientNames = settings.validation.excludedClients?.length
|
||||
? settings.validation.excludedClients
|
||||
: null;
|
||||
const period = createPeriod(`${settings.validation.period}d`);
|
||||
|
||||
const totalRequestCount = await this.operationsReader.getTotalAmountOfRequests({
|
||||
targetIds,
|
||||
excludedClients: excludedClientNames,
|
||||
period,
|
||||
});
|
||||
|
||||
return {
|
||||
conditionalBreakingChangeDiffConfig: {
|
||||
period,
|
||||
targetIds,
|
||||
excludedClientNames: settings.validation.excludedClients?.length
|
||||
? settings.validation.excludedClients
|
||||
: null,
|
||||
requestCountThreshold: totalRequestCount * settings.validation.percentage,
|
||||
},
|
||||
retentionInDays: settings.validation.period,
|
||||
percentage: settings.validation.percentage,
|
||||
totalRequestCount,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Failed to get settings`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getConditionalBreakingChangeMetadata(args: {
|
||||
conditionalBreakingChangeConfiguration: null | ConditionalBreakingChangeConfiguration;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
targetId: string;
|
||||
}): Promise<null | ConditionalBreakingChangeMetadata> {
|
||||
if (args.conditionalBreakingChangeConfiguration === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { conditionalBreakingChangeDiffConfig } = args.conditionalBreakingChangeConfiguration;
|
||||
|
||||
return {
|
||||
period: conditionalBreakingChangeDiffConfig.period,
|
||||
settings: {
|
||||
retentionInDays: args.conditionalBreakingChangeConfiguration.retentionInDays,
|
||||
excludedClientNames: conditionalBreakingChangeDiffConfig.excludedClientNames,
|
||||
percentage: args.conditionalBreakingChangeConfiguration.percentage,
|
||||
targets: await Promise.all(
|
||||
conditionalBreakingChangeDiffConfig.targetIds.map(async targetId => {
|
||||
return {
|
||||
id: targetId,
|
||||
name: (
|
||||
await this.targetManager.getTarget({
|
||||
organization: args.organizationId,
|
||||
project: args.projectId,
|
||||
target: args.targetId,
|
||||
})
|
||||
).name,
|
||||
};
|
||||
}),
|
||||
),
|
||||
},
|
||||
usage: {
|
||||
totalRequestCount: args.conditionalBreakingChangeConfiguration.totalRequestCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@sentry('SchemaPublisher.check')
|
||||
async check(input: CheckInput) {
|
||||
this.logger.info('Checking schema (input=%o)', lodash.omit(input, ['sdl']));
|
||||
|
|
@ -400,6 +509,11 @@ export class SchemaPublisher {
|
|||
? latestSchemaVersion
|
||||
: latestComposableSchemaVersion;
|
||||
|
||||
const conditionalBreakingChangeConfiguration =
|
||||
await this.getConditionalBreakingChangeConfiguration({
|
||||
selector,
|
||||
});
|
||||
|
||||
const schemaVersionContracts = comparedSchemaVersion
|
||||
? await this.contracts.getContractVersionsForSchemaVersion({
|
||||
schemaVersionId: comparedSchemaVersion.id,
|
||||
|
|
@ -430,6 +544,8 @@ export class SchemaPublisher {
|
|||
project,
|
||||
organization,
|
||||
approvedChanges: approvedSchemaChanges,
|
||||
conditionalBreakingChangeDiffConfig:
|
||||
conditionalBreakingChangeConfiguration?.conditionalBreakingChangeDiffConfig ?? null,
|
||||
});
|
||||
break;
|
||||
case ProjectType.FEDERATION:
|
||||
|
|
@ -475,6 +591,8 @@ export class SchemaPublisher {
|
|||
...contract,
|
||||
approvedChanges: approvedContractChanges?.get(contract.contract.id) ?? null,
|
||||
})) ?? null,
|
||||
conditionalBreakingChangeDiffConfig:
|
||||
conditionalBreakingChangeConfiguration?.conditionalBreakingChangeDiffConfig ?? null,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
|
|
@ -519,6 +637,12 @@ export class SchemaPublisher {
|
|||
githubSha: githubCheckRun?.commit ?? null,
|
||||
expiresAt,
|
||||
contextId,
|
||||
conditionalBreakingChangeMetadata: await this.getConditionalBreakingChangeMetadata({
|
||||
conditionalBreakingChangeConfiguration,
|
||||
organizationId: project.orgId,
|
||||
projectId: project.id,
|
||||
targetId: target.id,
|
||||
}),
|
||||
contracts:
|
||||
checkResult.state.contracts?.map(contract => ({
|
||||
contractId: contract.contractId,
|
||||
|
|
@ -557,6 +681,12 @@ export class SchemaPublisher {
|
|||
githubSha: githubCheckRun?.commit ?? null,
|
||||
expiresAt,
|
||||
contextId,
|
||||
conditionalBreakingChangeMetadata: await this.getConditionalBreakingChangeMetadata({
|
||||
conditionalBreakingChangeConfiguration,
|
||||
organizationId: project.orgId,
|
||||
projectId: project.id,
|
||||
targetId: target.id,
|
||||
}),
|
||||
contracts:
|
||||
checkResult.state?.contracts?.map(contract => ({
|
||||
contractId: contract.contractId,
|
||||
|
|
@ -617,6 +747,12 @@ export class SchemaPublisher {
|
|||
githubSha: githubCheckRun?.commit ?? null,
|
||||
expiresAt,
|
||||
contextId,
|
||||
conditionalBreakingChangeMetadata: await this.getConditionalBreakingChangeMetadata({
|
||||
conditionalBreakingChangeConfiguration,
|
||||
organizationId: project.orgId,
|
||||
projectId: project.id,
|
||||
targetId: target.id,
|
||||
}),
|
||||
contracts: schemaVersionContracts
|
||||
? await Promise.all(
|
||||
schemaVersionContracts?.edges.map(async edge => ({
|
||||
|
|
@ -1088,6 +1224,15 @@ export class SchemaPublisher {
|
|||
} as const;
|
||||
}
|
||||
|
||||
const conditionalBreakingChangeConfiguration =
|
||||
await this.getConditionalBreakingChangeConfiguration({
|
||||
selector: {
|
||||
target: input.target.id,
|
||||
project: input.project,
|
||||
organization: input.organization,
|
||||
},
|
||||
});
|
||||
|
||||
const contracts =
|
||||
project.type === ProjectType.FEDERATION
|
||||
? await this.contracts.loadActiveContractsWithLatestValidContractVersionsByTargetId({
|
||||
|
|
@ -1127,6 +1272,8 @@ export class SchemaPublisher {
|
|||
project: input.project,
|
||||
organization: input.organization,
|
||||
},
|
||||
conditionalBreakingChangeDiffConfig:
|
||||
conditionalBreakingChangeConfiguration?.conditionalBreakingChangeDiffConfig ?? null,
|
||||
contracts,
|
||||
});
|
||||
|
||||
|
|
@ -1199,6 +1346,12 @@ export class SchemaPublisher {
|
|||
});
|
||||
}
|
||||
},
|
||||
conditionalBreakingChangeMetadata: await this.getConditionalBreakingChangeMetadata({
|
||||
conditionalBreakingChangeConfiguration,
|
||||
organizationId: input.organization,
|
||||
projectId: input.project,
|
||||
targetId: input.target.id,
|
||||
}),
|
||||
});
|
||||
|
||||
const changes = deleteResult.state.changes ?? [];
|
||||
|
|
@ -1419,6 +1572,15 @@ export class SchemaPublisher {
|
|||
|
||||
this.logger.debug(`Found ${latestVersion?.schemas.length ?? 0} most recent schemas`);
|
||||
|
||||
const conditionalBreakingChangeConfiguration =
|
||||
await this.getConditionalBreakingChangeConfiguration({
|
||||
selector: {
|
||||
organization: organization.id,
|
||||
project: project.id,
|
||||
target: target.id,
|
||||
},
|
||||
});
|
||||
|
||||
const contracts =
|
||||
project.type === ProjectType.FEDERATION
|
||||
? await this.contracts.loadActiveContractsWithLatestValidContractVersionsByTargetId({
|
||||
|
|
@ -1473,6 +1635,8 @@ export class SchemaPublisher {
|
|||
project,
|
||||
target,
|
||||
baseSchema,
|
||||
conditionalBreakingChangeDiffConfig:
|
||||
conditionalBreakingChangeConfiguration?.conditionalBreakingChangeDiffConfig ?? null,
|
||||
});
|
||||
break;
|
||||
case ProjectType.FEDERATION:
|
||||
|
|
@ -1506,6 +1670,8 @@ export class SchemaPublisher {
|
|||
target,
|
||||
baseSchema,
|
||||
contracts,
|
||||
conditionalBreakingChangeDiffConfig:
|
||||
conditionalBreakingChangeConfiguration?.conditionalBreakingChangeDiffConfig ?? null,
|
||||
});
|
||||
break;
|
||||
default: {
|
||||
|
|
@ -1709,6 +1875,12 @@ export class SchemaPublisher {
|
|||
changes,
|
||||
diffSchemaVersionId,
|
||||
previousSchemaVersion: latestVersion?.version ?? null,
|
||||
conditionalBreakingChangeMetadata: await this.getConditionalBreakingChangeMetadata({
|
||||
conditionalBreakingChangeConfiguration,
|
||||
organizationId,
|
||||
projectId,
|
||||
targetId,
|
||||
}),
|
||||
contracts:
|
||||
publishResult.state.contracts?.map(contract => ({
|
||||
contractId: contract.contractId,
|
||||
|
|
|
|||
|
|
@ -220,8 +220,7 @@ export class SchemaVersionHelper {
|
|||
schemasAfter: ensureCompositeSchemas(schemasAfter),
|
||||
},
|
||||
filterOutFederationChanges: project.type === ProjectType.FEDERATION,
|
||||
// For things that we compute on the fly we just ignore the latest usage data.
|
||||
usageDataSelector: null,
|
||||
conditionalBreakingChangeConfig: null,
|
||||
});
|
||||
|
||||
if (diffCheck.status === 'skipped') {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import { TargetManager } from '../target/providers/target-manager';
|
|||
import type { SchemaModule } from './__generated__/types';
|
||||
import { extractSuperGraphInformation } from './lib/federation-super-graph';
|
||||
import { stripUsedSchemaCoordinatesFromDocumentNode } from './lib/unused-graphql';
|
||||
import { BreakingSchemaChangeUsageHelper } from './providers/breaking-schema-changes-helper';
|
||||
import { ContractsManager } from './providers/contracts-manager';
|
||||
import { SchemaCheckManager } from './providers/schema-check-manager';
|
||||
import { SchemaManager } from './providers/schema-manager';
|
||||
|
|
@ -794,6 +795,8 @@ export const resolvers: SchemaModule.Resolvers = {
|
|||
criticalityReason: change => change.reason,
|
||||
approval: change => change.approvalMetadata,
|
||||
isSafeBasedOnUsage: change => change.isSafeBasedOnUsage,
|
||||
usageStatistics: (change, _, { injector }) =>
|
||||
injector.get(BreakingSchemaChangeUsageHelper).getUsageDataForBreakingSchemaChange(change),
|
||||
},
|
||||
SchemaChangeApproval: {
|
||||
approvedBy: (approval, _, { injector }) =>
|
||||
|
|
@ -1662,6 +1665,9 @@ export const resolvers: SchemaModule.Resolvers = {
|
|||
previousSchemaSDL(schemaCheck, _, { injector }) {
|
||||
return injector.get(SchemaCheckManager).getPreviousSchemaSDL(schemaCheck);
|
||||
},
|
||||
conditionalBreakingChangeMetadata(schemaCheck, _, { injector }) {
|
||||
return injector.get(SchemaCheckManager).getConditionalBreakingChangeMetadata(schemaCheck);
|
||||
},
|
||||
},
|
||||
FailedSchemaCheck: {
|
||||
schemaVersion(schemaCheck, _, { injector }) {
|
||||
|
|
@ -1703,6 +1709,17 @@ export const resolvers: SchemaModule.Resolvers = {
|
|||
previousSchemaSDL(schemaCheck, _, { injector }) {
|
||||
return injector.get(SchemaCheckManager).getPreviousSchemaSDL(schemaCheck);
|
||||
},
|
||||
conditionalBreakingChangeMetadata(schemaCheck, _, { injector }) {
|
||||
return injector.get(SchemaCheckManager).getConditionalBreakingChangeMetadata(schemaCheck);
|
||||
},
|
||||
},
|
||||
BreakingChangeMetadataTarget: {
|
||||
target(record, _, { injector }) {
|
||||
return injector
|
||||
.get(TargetManager)
|
||||
.getTargetById({ targetId: record.id })
|
||||
.catch(() => null);
|
||||
},
|
||||
},
|
||||
SchemaPolicyWarningConnection: createDummyConnection(warning => ({
|
||||
...warning,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Injectable } from 'graphql-modules';
|
|||
import type { DatabasePool } from 'slonik';
|
||||
import type { PolicyConfigurationObject } from '@hive/policy';
|
||||
import type {
|
||||
ConditionalBreakingChangeMetadata,
|
||||
PaginatedSchemaVersionConnection,
|
||||
SchemaChangeType,
|
||||
SchemaCheck,
|
||||
|
|
@ -426,6 +427,7 @@ export interface Storage {
|
|||
actionFn(): Promise<void>;
|
||||
changes: Array<SchemaChangeType> | null;
|
||||
diffSchemaVersionId: string | null;
|
||||
conditionalBreakingChangeMetadata: null | ConditionalBreakingChangeMetadata;
|
||||
contracts: null | Array<CreateContractVersionInput>;
|
||||
} & TargetSelector &
|
||||
(
|
||||
|
|
@ -464,6 +466,7 @@ export interface Storage {
|
|||
sha: string;
|
||||
};
|
||||
contracts: null | Array<CreateContractVersionInput>;
|
||||
conditionalBreakingChangeMetadata: null | ConditionalBreakingChangeMetadata;
|
||||
} & TargetSelector) &
|
||||
(
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -253,8 +253,8 @@ export const resolvers: TargetModule.Resolvers = {
|
|||
target,
|
||||
project,
|
||||
organization,
|
||||
targets: input.targets,
|
||||
excludedClients: input.excludedClients ?? [],
|
||||
targets: result.data.targets,
|
||||
excludedClients: result.data.excludedClients ?? [],
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -342,8 +342,8 @@ export interface TargetSettings {
|
|||
enabled: boolean;
|
||||
period: number;
|
||||
percentage: number;
|
||||
targets: readonly string[];
|
||||
excludedClients: readonly string[];
|
||||
targets: string[];
|
||||
excludedClients: string[];
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -212,3 +212,72 @@ export function pushIfMissing<T>(list: T[], item: T): void {
|
|||
}
|
||||
|
||||
export type PromiseOrValue<T> = Promise<T> | T;
|
||||
|
||||
/**
|
||||
* Batch processing of items based on a built key
|
||||
*/
|
||||
export function batchBy<TItem, TResult>(
|
||||
/** Function to determine the batch group. */
|
||||
buildBatchKey: (arg: TItem) => string,
|
||||
/** Loader for each batch group. */
|
||||
loader: (args: TItem[]) => Promise<Promise<TResult>[]>,
|
||||
) {
|
||||
let batchGroups = new Map<
|
||||
string,
|
||||
{
|
||||
args: TItem[];
|
||||
callbacks: Array<{ resolve: (result: TResult) => void; reject: (error: Error) => void }>;
|
||||
}
|
||||
>();
|
||||
let didSchedule = false;
|
||||
|
||||
return (arg: TItem): Promise<TResult> => {
|
||||
const key = buildBatchKey(arg);
|
||||
let currentBatch = batchGroups.get(key);
|
||||
if (!currentBatch) {
|
||||
currentBatch = {
|
||||
args: [],
|
||||
callbacks: [],
|
||||
};
|
||||
batchGroups.set(key, currentBatch);
|
||||
}
|
||||
|
||||
if (!didSchedule) {
|
||||
didSchedule = true;
|
||||
process.nextTick(() => {
|
||||
for (const currentBatch of batchGroups.values()) {
|
||||
const tickArgs = [...currentBatch.args];
|
||||
const tickCallbacks = [...currentBatch.callbacks];
|
||||
|
||||
loader(tickArgs).then(
|
||||
promises => {
|
||||
for (let i = 0; i < tickCallbacks.length; i++) {
|
||||
promises[i].then(
|
||||
result => {
|
||||
tickCallbacks[i].resolve(result);
|
||||
},
|
||||
error => {
|
||||
tickCallbacks[i].reject(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
error => {
|
||||
for (let i = 0; i < tickCallbacks.length; i++) {
|
||||
tickCallbacks[i].reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
// reset the batch
|
||||
batchGroups = new Map();
|
||||
didSchedule = false;
|
||||
});
|
||||
}
|
||||
currentBatch.args.push(arg);
|
||||
const { callbacks } = currentBatch;
|
||||
return new Promise((resolve, reject) => {
|
||||
callbacks.push({ resolve, reject });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ export type WithGraphQLParentInfo<T> = T & {
|
|||
};
|
||||
};
|
||||
|
||||
export type BreakingChangeMetadataTarget = {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type SchemaCoordinateUsageForUnusedExplorer = {
|
||||
isUsed: false;
|
||||
usedCoordinates: Set<string>;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ export function sentry(
|
|||
};
|
||||
|
||||
const lastArgument = args.length > 0 ? (args[args.length - 1] as Span) : null;
|
||||
const passedSpan = lastArgument && 'spanId' in lastArgument ? lastArgument : null;
|
||||
const passedSpan =
|
||||
lastArgument && typeof lastArgument === 'object' && 'spanId' in lastArgument
|
||||
? lastArgument
|
||||
: null;
|
||||
|
||||
if (addToContext) {
|
||||
context = {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
"db:generate": "schemats generate --config schemats.cjs -o src/db/types.ts && prettier --write src/db/types.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-inspector/core": "5.0.2",
|
||||
"@graphql-inspector/core": "5.1.0-alpha-20231208113249-34700c8a",
|
||||
"@sentry/node": "7.102.1",
|
||||
"@sentry/types": "7.102.1",
|
||||
"@tgriesser/schemats": "9.0.1",
|
||||
|
|
|
|||
|
|
@ -232,6 +232,7 @@ export interface schema_checks {
|
|||
breaking_schema_changes: any | null;
|
||||
composite_schema_sdl: string | null;
|
||||
composite_schema_sdl_store_id: string | null;
|
||||
conditional_breaking_change_metadata: any | null;
|
||||
context_id: string | null;
|
||||
created_at: Date;
|
||||
expires_at: Date | null;
|
||||
|
|
@ -299,6 +300,7 @@ export interface schema_versions {
|
|||
action_id: string;
|
||||
base_schema: string | null;
|
||||
composite_schema_sdl: string | null;
|
||||
conditional_breaking_change_metadata: any | null;
|
||||
created_at: Date;
|
||||
diff_schema_version_id: string | null;
|
||||
github_repository: string | null;
|
||||
|
|
|
|||
|
|
@ -55,7 +55,10 @@ import {
|
|||
users,
|
||||
} from './db';
|
||||
import {
|
||||
ConditionalBreakingChangeMetadata,
|
||||
ConditionalBreakingChangeMetadataModel,
|
||||
HiveSchemaChangeModel,
|
||||
InsertConditionalBreakingChangeMetadataModel,
|
||||
SchemaCheckModel,
|
||||
SchemaCompositionErrorModel,
|
||||
SchemaPolicyWarningModel,
|
||||
|
|
@ -2526,6 +2529,7 @@ export async function createStorage(connection: string, maximumPoolSize: number)
|
|||
tags: args.tags,
|
||||
hasContractCompositionErrors:
|
||||
args.contracts?.some(c => c.schemaCompositionErrors != null) ?? false,
|
||||
conditionalBreakingChangeMetadata: args.conditionalBreakingChangeMetadata,
|
||||
});
|
||||
|
||||
// Move all the schema_version_to_log entries of the previous version to the new version
|
||||
|
|
@ -2628,6 +2632,7 @@ export async function createStorage(connection: string, maximumPoolSize: number)
|
|||
tags: input.tags,
|
||||
hasContractCompositionErrors:
|
||||
input.contracts?.some(c => c.schemaCompositionErrors != null) ?? false,
|
||||
conditionalBreakingChangeMetadata: input.conditionalBreakingChangeMetadata,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
|
|
@ -3992,6 +3997,7 @@ export async function createStorage(connection: string, maximumPoolSize: number)
|
|||
, "expires_at"
|
||||
, "context_id"
|
||||
, "has_contract_schema_changes"
|
||||
, "conditional_breaking_change_metadata"
|
||||
)
|
||||
VALUES (
|
||||
${schemaSDLHash}
|
||||
|
|
@ -4019,6 +4025,7 @@ export async function createStorage(connection: string, maximumPoolSize: number)
|
|||
c => c.breakingSchemaChanges?.length || c.safeSchemaChanges?.length,
|
||||
) ?? false
|
||||
}
|
||||
, ${jsonify(InsertConditionalBreakingChangeMetadataModel.parse(args.conditionalBreakingChangeMetadata))}
|
||||
)
|
||||
RETURNING
|
||||
"id"
|
||||
|
|
@ -4124,18 +4131,20 @@ export async function createStorage(connection: string, maximumPoolSize: number)
|
|||
, "schema_change"
|
||||
)
|
||||
SELECT * FROM ${sql.unnest(
|
||||
schemaCheck.breakingSchemaChanges.map(change => [
|
||||
schemaCheck.targetId,
|
||||
schemaCheck.contextId,
|
||||
change.id,
|
||||
JSON.stringify(
|
||||
toSerializableSchemaChange({
|
||||
...change,
|
||||
// We enhance the approved schema changes with some metadata that can be displayed on the UI
|
||||
approvalMetadata,
|
||||
}),
|
||||
),
|
||||
]),
|
||||
schemaCheck.breakingSchemaChanges
|
||||
.filter(change => !change.isSafeBasedOnUsage)
|
||||
.map(change => [
|
||||
schemaCheck.targetId,
|
||||
schemaCheck.contextId,
|
||||
change.id,
|
||||
JSON.stringify(
|
||||
toSerializableSchemaChange({
|
||||
...change,
|
||||
// We enhance the approved schema changes with some metadata that can be displayed on the UI
|
||||
approvalMetadata,
|
||||
}),
|
||||
),
|
||||
]),
|
||||
['uuid', 'text', 'text', 'jsonb'],
|
||||
)}
|
||||
ON CONFLICT ("target_id", "context_id", "schema_change_id") DO NOTHING
|
||||
|
|
@ -4165,7 +4174,7 @@ export async function createStorage(connection: string, maximumPoolSize: number)
|
|||
, "breaking_schema_changes" = (
|
||||
SELECT json_agg(
|
||||
CASE
|
||||
WHEN COALESCE(jsonb_typeof("change"->'approvalMetadata'), 'null') = 'null'
|
||||
WHEN (COALESCE(jsonb_typeof("change"->'approvalMetadata'), 'null') = 'null' AND "change"->>'isSafeBasedOnUsage' = 'false')
|
||||
THEN jsonb_set("change", '{approvalMetadata}', ${sql.jsonb(approvalMetadata)})
|
||||
ELSE "change"
|
||||
END
|
||||
|
|
@ -4780,6 +4789,7 @@ const SchemaVersionModel = zod.intersection(
|
|||
.boolean()
|
||||
.nullable()
|
||||
.transform(val => val ?? false),
|
||||
conditionalBreakingChangeMetadata: ConditionalBreakingChangeMetadataModel.nullable(),
|
||||
}),
|
||||
zod
|
||||
.union([
|
||||
|
|
@ -4925,6 +4935,7 @@ async function insertSchemaVersion(
|
|||
sha: string;
|
||||
repository: string;
|
||||
};
|
||||
conditionalBreakingChangeMetadata: null | ConditionalBreakingChangeMetadata;
|
||||
},
|
||||
) {
|
||||
const query = sql`
|
||||
|
|
@ -4944,7 +4955,8 @@ async function insertSchemaVersion(
|
|||
github_repository,
|
||||
github_sha,
|
||||
tags,
|
||||
has_contract_composition_errors
|
||||
has_contract_composition_errors,
|
||||
conditional_breaking_change_metadata
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
|
|
@ -4966,7 +4978,8 @@ async function insertSchemaVersion(
|
|||
${args.github?.repository ?? null},
|
||||
${args.github?.sha ?? null},
|
||||
${Array.isArray(args.tags) ? sql.array(args.tags, 'text') : null},
|
||||
${args.hasContractCompositionErrors}
|
||||
${args.hasContractCompositionErrors},
|
||||
${jsonify(InsertConditionalBreakingChangeMetadataModel.parse(args.conditionalBreakingChangeMetadata))}
|
||||
)
|
||||
RETURNING
|
||||
${schemaVersionSQLFields()}
|
||||
|
|
@ -5031,6 +5044,17 @@ export function toSerializableSchemaChange(change: SchemaChangeType): {
|
|||
schemaCheckId: string;
|
||||
};
|
||||
isSafeBasedOnUsage: boolean;
|
||||
usageStatistics: null | {
|
||||
topAffectedOperations: Array<{
|
||||
name: string;
|
||||
hash: string;
|
||||
count: number;
|
||||
}>;
|
||||
topAffectedClients: Array<{
|
||||
name: string;
|
||||
count: number;
|
||||
}>;
|
||||
};
|
||||
} {
|
||||
return {
|
||||
id: change.id,
|
||||
|
|
@ -5038,6 +5062,7 @@ export function toSerializableSchemaChange(change: SchemaChangeType): {
|
|||
meta: change.meta,
|
||||
isSafeBasedOnUsage: change.isSafeBasedOnUsage,
|
||||
approvalMetadata: change.approvalMetadata,
|
||||
usageStatistics: change.usageStatistics,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -5064,6 +5089,7 @@ const schemaCheckSQLFields = sql`
|
|||
, coalesce(c."is_manually_approved", false) as "isManuallyApproved"
|
||||
, c."manual_approval_user_id" as "manualApprovalUserId"
|
||||
, c."context_id" as "contextId"
|
||||
, c."conditional_breaking_change_metadata" as "conditionalBreakingChangeMetadata"
|
||||
`;
|
||||
|
||||
const schemaVersionSQLFields = (t = sql``) => sql`
|
||||
|
|
@ -5083,6 +5109,7 @@ const schemaVersionSQLFields = (t = sql``) => sql`
|
|||
, ${t}"record_version" as "recordVersion"
|
||||
, ${t}"tags"
|
||||
, ${t}"has_contract_composition_errors" as "hasContractCompositionErrors"
|
||||
, ${t}"conditional_breaking_change_metadata" as "conditionalBreakingChangeMetadata"
|
||||
`;
|
||||
|
||||
const targetSQLFields = sql`
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import crypto from 'node:crypto';
|
|||
import stableJSONStringify from 'fast-json-stable-stringify';
|
||||
import { SerializableValue } from 'slonik';
|
||||
import { z } from 'zod';
|
||||
import { CriticalityLevel } from '@graphql-inspector/core';
|
||||
import type {
|
||||
import {
|
||||
ChangeType,
|
||||
CriticalityLevel,
|
||||
DirectiveAddedChange,
|
||||
DirectiveArgumentAddedChange,
|
||||
DirectiveArgumentDefaultValueChangedChange,
|
||||
|
|
@ -817,6 +817,10 @@ const ApprovalMetadataModel = z.object({
|
|||
|
||||
export type SchemaCheckApprovalMetadata = z.TypeOf<typeof ApprovalMetadataModel>;
|
||||
|
||||
function isInputFieldAddedChange(change: Change): change is z.TypeOf<typeof InputFieldAddedModel> {
|
||||
return change.type === 'INPUT_FIELD_ADDED';
|
||||
}
|
||||
|
||||
export const HiveSchemaChangeModel = z
|
||||
.intersection(
|
||||
SchemaChangeModel,
|
||||
|
|
@ -828,6 +832,25 @@ export const HiveSchemaChangeModel = z
|
|||
approvalMetadata: ApprovalMetadataModel.nullable()
|
||||
.optional()
|
||||
.transform(value => value ?? null),
|
||||
usageStatistics: z
|
||||
.object({
|
||||
topAffectedOperations: z.array(
|
||||
z.object({
|
||||
hash: z.string(),
|
||||
name: z.string(),
|
||||
count: z.number(),
|
||||
}),
|
||||
),
|
||||
topAffectedClients: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
count: z.number(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform(value => value ?? null),
|
||||
}),
|
||||
)
|
||||
// We inflate the schema check when reading it from the database
|
||||
|
|
@ -847,10 +870,29 @@ export const HiveSchemaChangeModel = z
|
|||
readonly message: string;
|
||||
readonly path: string | null;
|
||||
readonly approvalMetadata: SchemaCheckApprovalMetadata | null;
|
||||
readonly isSafeBasedOnUsage: boolean;
|
||||
isSafeBasedOnUsage: boolean;
|
||||
usageStatistics: {
|
||||
topAffectedOperations: { hash: string; name: string; count: number }[];
|
||||
topAffectedClients: { name: string; count: number }[];
|
||||
} | null;
|
||||
readonly breakingChangeSchemaCoordinate: string | null;
|
||||
} => {
|
||||
const change = schemaChangeFromSerializableChange(rawChange as any);
|
||||
|
||||
/** The schema coordinate used for detecting whether something is a breaking change can be different based on the change type. */
|
||||
let breakingChangeSchemaCoordinate: string | null = null;
|
||||
|
||||
if (change.criticality.level === CriticalityLevel.Breaking) {
|
||||
breakingChangeSchemaCoordinate = change.path ?? null;
|
||||
|
||||
if (
|
||||
isInputFieldAddedChange(rawChange) &&
|
||||
rawChange.meta.isAddedInputFieldTypeNullable === false
|
||||
) {
|
||||
breakingChangeSchemaCoordinate = rawChange.meta.inputName;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get id() {
|
||||
return rawChange.id ?? createSchemaChangeId(change);
|
||||
|
|
@ -861,8 +903,14 @@ export const HiveSchemaChangeModel = z
|
|||
message: change.message,
|
||||
meta: change.meta,
|
||||
path: change.path ?? null,
|
||||
isSafeBasedOnUsage: rawChange.isSafeBasedOnUsage ?? false,
|
||||
isSafeBasedOnUsage:
|
||||
// only breaking changes can be safe based on usage
|
||||
(change.criticality.level === CriticalityLevel.Breaking &&
|
||||
rawChange.isSafeBasedOnUsage) ||
|
||||
false,
|
||||
reason: change.criticality.reason ?? null,
|
||||
usageStatistics: rawChange.usageStatistics ?? null,
|
||||
breakingChangeSchemaCoordinate,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
|
@ -946,6 +994,45 @@ const ContractCheckInput = z.object({
|
|||
safeSchemaChanges: z.array(HiveSchemaChangeModel).nullable(),
|
||||
});
|
||||
|
||||
const DateOrString = z
|
||||
.union([z.date(), z.string()])
|
||||
.transform(value => (typeof value === 'string' ? new Date(value) : value));
|
||||
|
||||
export const ConditionalBreakingChangeMetadataModel = z.object({
|
||||
period: z.object({
|
||||
from: DateOrString,
|
||||
to: DateOrString,
|
||||
}),
|
||||
settings: z.object({
|
||||
retentionInDays: z.number(),
|
||||
percentage: z.number(),
|
||||
excludedClientNames: z.array(z.string()).nullable(),
|
||||
/** we keep both reference to id and name so in case target gets deleted we can still display the name */
|
||||
targets: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
usage: z.object({
|
||||
totalRequestCount: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type ConditionalBreakingChangeMetadata = z.TypeOf<
|
||||
typeof ConditionalBreakingChangeMetadataModel
|
||||
>;
|
||||
|
||||
export const InsertConditionalBreakingChangeMetadataModel =
|
||||
ConditionalBreakingChangeMetadataModel.transform(data => ({
|
||||
...data,
|
||||
period: {
|
||||
from: data.period.from.toISOString(),
|
||||
to: data.period.to.toISOString(),
|
||||
},
|
||||
})).nullable();
|
||||
|
||||
const SchemaCheckInputModel = z.union([
|
||||
z.intersection(
|
||||
z.object({
|
||||
|
|
@ -955,6 +1042,7 @@ const SchemaCheckInputModel = z.union([
|
|||
...NotManuallyApprovedSchemaCheckFields,
|
||||
...SchemaCheckSharedInputFields,
|
||||
contracts: z.array(ContractCheckInput).nullable(),
|
||||
conditionalBreakingChangeMetadata: ConditionalBreakingChangeMetadataModel.nullable(),
|
||||
}),
|
||||
z.union([
|
||||
z.object(FailedSchemaCompositionInputFields),
|
||||
|
|
@ -969,6 +1057,7 @@ const SchemaCheckInputModel = z.union([
|
|||
...SuccessfulSchemaCompositionInputFields,
|
||||
...SchemaCheckSharedInputFields,
|
||||
contracts: z.array(ContractCheckInput).nullable(),
|
||||
conditionalBreakingChangeMetadata: ConditionalBreakingChangeMetadataModel.nullable(),
|
||||
}),
|
||||
z.union([
|
||||
z.object({ ...ManuallyApprovedSchemaCheckFields }),
|
||||
|
|
@ -992,6 +1081,7 @@ export const SchemaCheckModel = z.union([
|
|||
...PersistedSchemaCheckFields,
|
||||
...NotManuallyApprovedSchemaCheckFields,
|
||||
...SchemaCheckSharedOutputFields,
|
||||
conditionalBreakingChangeMetadata: ConditionalBreakingChangeMetadataModel.nullable(),
|
||||
}),
|
||||
z.union([
|
||||
z.object(FailedSchemaCompositionOutputFields),
|
||||
|
|
@ -1006,6 +1096,7 @@ export const SchemaCheckModel = z.union([
|
|||
...SuccessfulSchemaCompositionOutputFields,
|
||||
...PersistedSchemaCheckFields,
|
||||
...SchemaCheckSharedOutputFields,
|
||||
conditionalBreakingChangeMetadata: ConditionalBreakingChangeMetadataModel.nullable(),
|
||||
}),
|
||||
z.union([
|
||||
z.object({ ...ManuallyApprovedSchemaCheckFields }),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import NextLink from 'next/link';
|
||||
import clsx from 'clsx';
|
||||
import { format } from 'date-fns';
|
||||
import { GitCompareIcon } from 'lucide-react';
|
||||
import { useMutation, useQuery } from 'urql';
|
||||
import { authenticated } from '@/components/authenticated-container';
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
labelize,
|
||||
NoGraphChanges,
|
||||
} from '@/components/target/history/errors-and-changes';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Subtitle, Title } from '@/components/ui/page';
|
||||
import { QueryError } from '@/components/ui/query-error';
|
||||
|
|
@ -26,6 +28,7 @@ import {
|
|||
Button as LegacyButton,
|
||||
MetaTitle,
|
||||
Modal,
|
||||
Spinner,
|
||||
Switch,
|
||||
TimeAgo,
|
||||
} from '@/components/v2';
|
||||
|
|
@ -39,6 +42,7 @@ import {
|
|||
CheckIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ExternalLinkIcon,
|
||||
InfoCircledIcon,
|
||||
ListBulletIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
|
||||
|
|
@ -353,6 +357,37 @@ const ApproveFailedSchemaCheckModal = (props: {
|
|||
);
|
||||
};
|
||||
|
||||
const BreakingChangesTitle = () => {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
Breaking Changes
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button variant="ghost" size="icon-sm" className="ml-1">
|
||||
<InfoCircledIcon className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="start">
|
||||
<div className="mb-2 max-w-[500px] font-normal">
|
||||
<h5 className="mb-1 text-lg font-bold">Breaking Changes</h5>
|
||||
<p className="mb-2 text-sm">Schema changes that can potentially break clients.</p>
|
||||
<h6 className="mb-1 font-bold">Breaking Change Approval</h6>
|
||||
<p className="mb-2">
|
||||
Approve this schema check for adding the changes to the list of allowed changes and
|
||||
change the status of this schema check to successful.
|
||||
</p>
|
||||
<h6 className="mb-1 font-bold">Conditional Breaking Changes</h6>
|
||||
<p>
|
||||
Configure conditional breaking changes, to automatically mark breaking changes as safe
|
||||
based on live usage data collected from your GraphQL Gateway.
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const ActiveSchemaCheck = ({
|
||||
schemaCheckId,
|
||||
}: {
|
||||
|
|
@ -377,6 +412,17 @@ const ActiveSchemaCheck = ({
|
|||
query.data?.target?.schemaCheck,
|
||||
);
|
||||
|
||||
if (query.fetching) {
|
||||
return (
|
||||
<div className="flex h-fit flex-1 items-center justify-center self-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<Spinner className="size-12" />
|
||||
<div className="mt-3">Loading Schema Check...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!schemaCheck) {
|
||||
return (
|
||||
<EmptyList
|
||||
|
|
@ -550,6 +596,7 @@ const SchemaChecksView_SchemaCheckFragment = graphql(`
|
|||
}
|
||||
}
|
||||
...DefaultSchemaView_SchemaCheckFragment
|
||||
...ContractCheckView_SchemaCheckFragment
|
||||
}
|
||||
`);
|
||||
|
||||
|
|
@ -655,7 +702,7 @@ function SchemaChecksView(props: {
|
|||
</TabsList>
|
||||
</Tabs>
|
||||
{selectedContractCheckNode ? (
|
||||
<ContractCheckView contractCheck={selectedContractCheckNode} />
|
||||
<ContractCheckView contractCheck={selectedContractCheckNode} schemaCheck={schemaCheck} />
|
||||
) : (
|
||||
<DefaultSchemaView schemaCheck={schemaCheck} />
|
||||
)}
|
||||
|
|
@ -701,6 +748,19 @@ const DefaultSchemaView_SchemaCheckFragment = graphql(`
|
|||
schemaCheckId
|
||||
}
|
||||
isSafeBasedOnUsage
|
||||
usageStatistics {
|
||||
topAffectedOperations {
|
||||
hash
|
||||
name
|
||||
countFormatted
|
||||
percentageFormatted
|
||||
}
|
||||
topAffectedClients {
|
||||
name
|
||||
countFormatted
|
||||
percentageFormatted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
safeSchemaChanges {
|
||||
|
|
@ -744,6 +804,10 @@ const DefaultSchemaView_SchemaCheckFragment = graphql(`
|
|||
}
|
||||
}
|
||||
}
|
||||
conditionalBreakingChangeMetadata {
|
||||
...ChangesBlock_SchemaCheckConditionalBreakingChangeMetadataFragment
|
||||
}
|
||||
...ConditionalBreakingChangesMetadataSection_SchemaCheckFragment
|
||||
}
|
||||
`);
|
||||
|
||||
|
|
@ -824,23 +888,26 @@ function DefaultSchemaView(props: {
|
|||
<CompositionErrorsSection compositionErrors={schemaCheck.compositionErrors} />
|
||||
)}
|
||||
{schemaCheck.breakingSchemaChanges?.nodes.length ? (
|
||||
<div className="mb-2">
|
||||
<div className="mb-5">
|
||||
<ChangesBlock
|
||||
title={<BreakingChangesTitle />}
|
||||
criticality={CriticalityLevel.Breaking}
|
||||
changes={schemaCheck.breakingSchemaChanges.nodes}
|
||||
conditionBreakingChangeMetadata={schemaCheck.conditionalBreakingChangeMetadata}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{schemaCheck.safeSchemaChanges ? (
|
||||
<div className="mb-2">
|
||||
<div className="mb-5">
|
||||
<ChangesBlock
|
||||
title="Safe Changes"
|
||||
criticality={CriticalityLevel.Safe}
|
||||
changes={schemaCheck.safeSchemaChanges.nodes}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{schemaCheck.schemaPolicyErrors?.edges.length ? (
|
||||
<div className="mb-2">
|
||||
<div className="mb-5">
|
||||
<PolicyBlock
|
||||
title="Schema Policy Errors"
|
||||
policies={schemaCheck.schemaPolicyErrors}
|
||||
|
|
@ -849,7 +916,7 @@ function DefaultSchemaView(props: {
|
|||
</div>
|
||||
) : null}
|
||||
{schemaCheck.schemaPolicyWarnings ? (
|
||||
<div className="mb-2">
|
||||
<div className="mb-5">
|
||||
<PolicyBlock
|
||||
title="Schema Policy Warnings"
|
||||
policies={schemaCheck.schemaPolicyWarnings}
|
||||
|
|
@ -857,6 +924,7 @@ function DefaultSchemaView(props: {
|
|||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<ConditionalBreakingChangesMetadataSection schemaCheck={schemaCheck} />
|
||||
</div>
|
||||
)}
|
||||
{selectedView === 'service' && (
|
||||
|
|
@ -899,6 +967,99 @@ function DefaultSchemaView(props: {
|
|||
);
|
||||
}
|
||||
|
||||
const ConditionalBreakingChangesMetadataSection_SchemaCheckFragment = graphql(`
|
||||
fragment ConditionalBreakingChangesMetadataSection_SchemaCheckFragment on SchemaCheck {
|
||||
id
|
||||
conditionalBreakingChangeMetadata {
|
||||
period {
|
||||
from
|
||||
to
|
||||
}
|
||||
settings {
|
||||
retentionInDays
|
||||
percentage
|
||||
excludedClientNames
|
||||
targets {
|
||||
name
|
||||
target {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
usage {
|
||||
totalRequestCountFormatted
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
function ConditionalBreakingChangesMetadataSection(props: {
|
||||
schemaCheck: FragmentType<typeof ConditionalBreakingChangesMetadataSection_SchemaCheckFragment>;
|
||||
}) {
|
||||
const schemaCheck = useFragment(
|
||||
ConditionalBreakingChangesMetadataSection_SchemaCheckFragment,
|
||||
props.schemaCheck,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mb-5 mt-10 text-sm text-gray-400">
|
||||
{schemaCheck.conditionalBreakingChangeMetadata ? (
|
||||
<p>
|
||||
Based on{' '}
|
||||
<span className="text-white">
|
||||
{schemaCheck.conditionalBreakingChangeMetadata.usage.totalRequestCountFormatted}{' '}
|
||||
requests
|
||||
</span>{' '}
|
||||
from target
|
||||
{schemaCheck.conditionalBreakingChangeMetadata.settings.targets.length === 1
|
||||
? ''
|
||||
: 's'}{' '}
|
||||
{schemaCheck.conditionalBreakingChangeMetadata.settings.targets.map(
|
||||
(target, index, arr) => (
|
||||
<>
|
||||
<span className="text-white">{target.name}</span>
|
||||
{index === arr.length - 1 ? null : index === arr.length - 2 ? 'and' : ','}
|
||||
</>
|
||||
),
|
||||
)}
|
||||
. <br />
|
||||
Usage data ranges from{' '}
|
||||
<span className="text-white">
|
||||
{format(schemaCheck.conditionalBreakingChangeMetadata.period.from, 'do MMM yyyy HH:mm')}
|
||||
</span>{' '}
|
||||
to{' '}
|
||||
<span className="text-white">
|
||||
{format(schemaCheck.conditionalBreakingChangeMetadata.period.to, 'do MMM yyyy HH:mm')} (
|
||||
{format(schemaCheck.conditionalBreakingChangeMetadata.period.to, 'z')})
|
||||
</span>{' '}
|
||||
(period of {schemaCheck.conditionalBreakingChangeMetadata.settings.retentionInDays} day
|
||||
{schemaCheck.conditionalBreakingChangeMetadata.settings.retentionInDays === 1 ? '' : 's'}
|
||||
).
|
||||
<br />
|
||||
<DocsLink
|
||||
href="/management/targets#conditional-breaking-changes"
|
||||
className="text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
Learn more about conditional breaking changes.
|
||||
</DocsLink>
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Get more out of schema checks by enabling conditional breaking changes based on usage
|
||||
data.
|
||||
<br />
|
||||
<DocsLink
|
||||
href="/management/targets#conditional-breaking-changes"
|
||||
className="text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
Learn more about conditional breaking changes.
|
||||
</DocsLink>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ContractCheckView_ContractCheckFragment = graphql(`
|
||||
fragment ContractCheckView_ContractCheckFragment on ContractCheck {
|
||||
id
|
||||
|
|
@ -920,6 +1081,19 @@ const ContractCheckView_ContractCheckFragment = graphql(`
|
|||
schemaCheckId
|
||||
}
|
||||
isSafeBasedOnUsage
|
||||
usageStatistics {
|
||||
topAffectedOperations {
|
||||
hash
|
||||
name
|
||||
countFormatted
|
||||
percentageFormatted
|
||||
}
|
||||
topAffectedClients {
|
||||
name
|
||||
countFormatted
|
||||
percentageFormatted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
safeSchemaChanges {
|
||||
|
|
@ -949,10 +1123,22 @@ const ContractCheckView_ContractCheckFragment = graphql(`
|
|||
}
|
||||
`);
|
||||
|
||||
const ContractCheckView_SchemaCheckFragment = graphql(`
|
||||
fragment ContractCheckView_SchemaCheckFragment on SchemaCheck {
|
||||
id
|
||||
...ConditionalBreakingChangesMetadataSection_SchemaCheckFragment
|
||||
conditionalBreakingChangeMetadata {
|
||||
...ChangesBlock_SchemaCheckConditionalBreakingChangeMetadataFragment
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
function ContractCheckView(props: {
|
||||
contractCheck: FragmentType<typeof ContractCheckView_ContractCheckFragment>;
|
||||
schemaCheck: FragmentType<typeof ContractCheckView_SchemaCheckFragment>;
|
||||
}) {
|
||||
const contractCheck = useFragment(ContractCheckView_ContractCheckFragment, props.contractCheck);
|
||||
const schemaCheck = useFragment(ContractCheckView_SchemaCheckFragment, props.schemaCheck);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<string>('details');
|
||||
|
||||
|
|
@ -1014,22 +1200,29 @@ function ContractCheckView(props: {
|
|||
{contractCheck.breakingSchemaChanges?.nodes.length && (
|
||||
<div className="mb-2">
|
||||
<ChangesBlock
|
||||
title={<BreakingChangesTitle />}
|
||||
criticality={CriticalityLevel.Breaking}
|
||||
changes={contractCheck.breakingSchemaChanges.nodes}
|
||||
conditionBreakingChangeMetadata={schemaCheck.conditionalBreakingChangeMetadata}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{contractCheck.safeSchemaChanges && (
|
||||
<div className="mb-2">
|
||||
<ChangesBlock
|
||||
title="Safe Changes"
|
||||
criticality={CriticalityLevel.Safe}
|
||||
changes={contractCheck.safeSchemaChanges.nodes}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!contractCheck.breakingSchemaChanges &&
|
||||
!contractCheck.safeSchemaChanges &&
|
||||
!contractCheck.schemaCompositionErrors && <NoGraphChanges />}
|
||||
!contractCheck.safeSchemaChanges &&
|
||||
!contractCheck.schemaCompositionErrors ? (
|
||||
<NoGraphChanges />
|
||||
) : (
|
||||
<ConditionalBreakingChangesMetadataSection schemaCheck={schemaCheck} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectedView === 'schema' && (
|
||||
|
|
@ -1064,6 +1257,9 @@ const ChecksPageQuery = graphql(`
|
|||
organization(selector: { organization: $organizationId }) {
|
||||
organization {
|
||||
...TargetLayout_CurrentOrganizationFragment
|
||||
rateLimit {
|
||||
retentionInDays
|
||||
}
|
||||
}
|
||||
}
|
||||
project(selector: { organization: $organizationId, project: $projectId }) {
|
||||
|
|
|
|||
|
|
@ -472,6 +472,7 @@ function DefaultSchemaVersionView(props: {
|
|||
{schemaVersion.breakingSchemaChanges?.nodes.length && (
|
||||
<div className="mb-2">
|
||||
<ChangesBlock
|
||||
title="Breaking Changes"
|
||||
criticality={CriticalityLevel.Breaking}
|
||||
changes={schemaVersion.breakingSchemaChanges.nodes}
|
||||
/>
|
||||
|
|
@ -480,6 +481,7 @@ function DefaultSchemaVersionView(props: {
|
|||
{schemaVersion.safeSchemaChanges?.nodes?.length && (
|
||||
<div className="mb-2">
|
||||
<ChangesBlock
|
||||
title="Safe Changes"
|
||||
criticality={CriticalityLevel.Safe}
|
||||
changes={schemaVersion.safeSchemaChanges.nodes}
|
||||
/>
|
||||
|
|
@ -651,6 +653,7 @@ function ContractVersionView(props: {
|
|||
{contractVersion.breakingSchemaChanges?.nodes.length && (
|
||||
<div className="mb-2">
|
||||
<ChangesBlock
|
||||
title="Breaking Changes"
|
||||
criticality={CriticalityLevel.Breaking}
|
||||
changes={contractVersion.breakingSchemaChanges.nodes}
|
||||
/>
|
||||
|
|
@ -659,6 +662,7 @@ function ContractVersionView(props: {
|
|||
{contractVersion.safeSchemaChanges?.nodes?.length && (
|
||||
<div className="mb-2">
|
||||
<ChangesBlock
|
||||
title="Safe Changes"
|
||||
criticality={CriticalityLevel.Safe}
|
||||
changes={contractVersion.safeSchemaChanges.nodes}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -104,47 +104,43 @@ export function SchemaExplorerUsageStats(props: {
|
|||
</ul>
|
||||
|
||||
{Array.isArray(usage.topOperations) ? (
|
||||
<>
|
||||
<table className="mt-4 table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2 pl-0 text-left">Top 5 Operations</th>
|
||||
<th className="p-2 text-center">Reqs</th>
|
||||
<th className="p-2 text-center">Of total</th>
|
||||
<table className="mt-4 table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2 pl-0 text-left">Top 5 Operations</th>
|
||||
<th className="p-2 text-center">Reqs</th>
|
||||
<th className="p-2 text-center">Of total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usage.topOperations.map(op => (
|
||||
<tr key={op.hash}>
|
||||
<td className="px-2 pl-0 text-left">
|
||||
<NextLink
|
||||
className="text-orange-500 hover:text-orange-500 hover:underline hover:underline-offset-2"
|
||||
href={{
|
||||
pathname:
|
||||
'/[organizationId]/[projectId]/[targetId]/insights/[operationName]/[operationHash]',
|
||||
query: {
|
||||
organizationId: props.organizationCleanId,
|
||||
projectId: props.projectCleanId,
|
||||
targetId: props.targetCleanId,
|
||||
operationName: `${op.hash.substring(0, 4)}_${op.name}`,
|
||||
operationHash: op.hash,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{op.hash.substring(0, 4)}_{op.name}
|
||||
</NextLink>
|
||||
</td>
|
||||
<td className="px-2 text-center font-bold">{formatNumber(op.count)}</td>
|
||||
<td className="px-2 text-center font-bold">
|
||||
{toDecimal((op.count / props.totalRequests) * 100)}%
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usage.topOperations.map(op => (
|
||||
<tr key={op.hash}>
|
||||
<td className="px-2 pl-0 text-left">
|
||||
<NextLink
|
||||
className="text-orange-500 hover:text-orange-500 hover:underline hover:underline-offset-2"
|
||||
href={{
|
||||
pathname:
|
||||
'/[organizationId]/[projectId]/[targetId]/insights/[operationName]/[operationHash]',
|
||||
query: {
|
||||
organizationId: props.organizationCleanId,
|
||||
projectId: props.projectCleanId,
|
||||
targetId: props.targetCleanId,
|
||||
operationName: `${op.hash.substring(0, 4)}_${op.name}`,
|
||||
operationHash: op.hash,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{op.hash.substring(0, 4)}_{op.name}
|
||||
</NextLink>
|
||||
</td>
|
||||
<td className="px-2 text-center font-bold">
|
||||
{formatNumber(op.count)}
|
||||
</td>
|
||||
<td className="px-2 text-center font-bold">
|
||||
{toDecimal((op.count / props.totalRequests) * 100)}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,30 @@
|
|||
import { ReactElement, useMemo } from 'react';
|
||||
import { ReactElement } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { clsx } from 'clsx';
|
||||
import { format } from 'date-fns';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
import reactStringReplace from 'react-string-replace';
|
||||
import { Label, Label as LegacyLabel } from '@/components/common';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Heading } from '@/components/v2';
|
||||
import { Tooltip as LegacyTooltip } from '@/components/v2/tooltip';
|
||||
import { PulseIcon } from '@/components/v2/icon';
|
||||
import { FragmentType, graphql, useFragment } from '@/gql';
|
||||
import { CriticalityLevel, SchemaChangeFieldsFragment } from '@/graphql';
|
||||
import { useRouteSelector } from '@/lib/hooks/use-route-selector';
|
||||
|
|
@ -22,220 +38,283 @@ export function labelize(message: string) {
|
|||
});
|
||||
}
|
||||
|
||||
const titleMap: Record<CriticalityLevel, string> = {
|
||||
Safe: 'Safe Changes',
|
||||
Breaking: 'Breaking Changes',
|
||||
Dangerous: 'Dangerous Changes',
|
||||
};
|
||||
|
||||
const criticalityLevelMapping = {
|
||||
[CriticalityLevel.Safe]: clsx('text-emerald-400'),
|
||||
[CriticalityLevel.Dangerous]: clsx('text-yellow-400'),
|
||||
} as Record<CriticalityLevel, string>;
|
||||
|
||||
const ChangesBlock_SchemaCheckConditionalBreakingChangeMetadataFragment = graphql(`
|
||||
fragment ChangesBlock_SchemaCheckConditionalBreakingChangeMetadataFragment on SchemaCheckConditionalBreakingChangeMetadata {
|
||||
settings {
|
||||
retentionInDays
|
||||
targets {
|
||||
name
|
||||
target {
|
||||
id
|
||||
cleanId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export function ChangesBlock(props: {
|
||||
changes: SchemaChangeFieldsFragment[];
|
||||
title: string | React.ReactElement;
|
||||
criticality: CriticalityLevel;
|
||||
changes: SchemaChangeFieldsFragment[];
|
||||
conditionBreakingChangeMetadata?: FragmentType<
|
||||
typeof ChangesBlock_SchemaCheckConditionalBreakingChangeMetadataFragment
|
||||
> | null;
|
||||
}): ReactElement | null {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{titleMap[props.criticality]}
|
||||
</h2>
|
||||
<ul className="list-inside list-disc pl-3 text-sm leading-relaxed">
|
||||
<h2 className="mb-3 font-bold text-gray-900 dark:text-white">{props.title}</h2>
|
||||
<div className="list-inside list-disc space-y-2 text-sm leading-relaxed">
|
||||
{props.changes.map((change, key) => (
|
||||
<li
|
||||
<ChangeItem
|
||||
key={key}
|
||||
className={clsx(criticalityLevelMapping[props.criticality] ?? 'text-red-400', ' my-1')}
|
||||
>
|
||||
<MaybeWrapTooltip tooltip={change.criticalityReason ?? null}>
|
||||
<span className="text-gray-600 dark:text-white">{labelize(change.message)}</span>
|
||||
</MaybeWrapTooltip>
|
||||
{change.isSafeBasedOnUsage ? (
|
||||
<span className="cursor-pointer text-yellow-500">
|
||||
{' '}
|
||||
<CheckIcon className="inline size-3" /> Safe based on usage data
|
||||
</span>
|
||||
) : null}
|
||||
{change.approval ? <SchemaChangeApproval approval={change.approval} /> : null}
|
||||
</li>
|
||||
change={change}
|
||||
conditionBreakingChangeMetadata={props.conditionBreakingChangeMetadata ?? null}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SchemaChangeApproval = (props: {
|
||||
approval: Exclude<SchemaChangeFieldsFragment['approval'], null | undefined>;
|
||||
}) => {
|
||||
const approvalName = props.approval.approvedBy?.displayName ?? '<unknown>';
|
||||
const approvalDate = useMemo(
|
||||
() => format(new Date(props.approval.approvedAt), 'do MMMM yyyy'),
|
||||
[props.approval.approvedAt],
|
||||
);
|
||||
const route = useRouteSelector();
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const schemaCheckPath = useMemo(
|
||||
() =>
|
||||
'/' +
|
||||
[
|
||||
route.organizationId,
|
||||
route.projectId,
|
||||
route.targetId,
|
||||
'checks',
|
||||
props.approval.schemaCheckId,
|
||||
].join('/'),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<LegacyTooltip.Provider delayDuration={200}>
|
||||
<LegacyTooltip
|
||||
content={
|
||||
<>
|
||||
This breaking change was manually{' '}
|
||||
{props.approval.schemaCheckId === route.schemaCheckId ? (
|
||||
<>
|
||||
{' '}
|
||||
approved by {approvalName} in this check on {approvalDate}.
|
||||
</>
|
||||
) : (
|
||||
<Link href={schemaCheckPath} className="text-orange-500 hover:underline">
|
||||
approved by {approvalName} on {approvalDate}.
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<span className="cursor-pointer text-green-500">
|
||||
{' '}
|
||||
<CheckIcon className="inline size-3" /> Approved by {approvalName}
|
||||
</span>
|
||||
</LegacyTooltip>
|
||||
</LegacyTooltip.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
function MaybeWrapTooltip(props: { children: React.ReactNode; tooltip: string | null }) {
|
||||
return props.tooltip ? (
|
||||
<LegacyTooltip.Provider delayDuration={200}>
|
||||
<LegacyTooltip content={props.tooltip}>{props.children}</LegacyTooltip>
|
||||
</LegacyTooltip.Provider>
|
||||
) : (
|
||||
<>{props.children}</>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorsBlock({ title, errors }: { errors: string[]; title: React.ReactNode }) {
|
||||
if (!errors.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-900 dark:text-white">{title}</h2>
|
||||
<ul className="list-inside list-disc pl-3 text-sm leading-relaxed">
|
||||
{errors.map((error, key) => (
|
||||
<li key={key} className="my-1 text-red-400">
|
||||
<span className="text-gray-600 dark:text-white">{labelize(error)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VersionErrorsAndChanges(props: {
|
||||
changes: {
|
||||
nodes: SchemaChangeFieldsFragment[];
|
||||
total: number;
|
||||
};
|
||||
errors: {
|
||||
nodes: Array<{
|
||||
message: string;
|
||||
}>;
|
||||
total: number;
|
||||
};
|
||||
function ChangeItem(props: {
|
||||
change: SchemaChangeFieldsFragment;
|
||||
conditionBreakingChangeMetadata: FragmentType<
|
||||
typeof ChangesBlock_SchemaCheckConditionalBreakingChangeMetadataFragment
|
||||
> | null;
|
||||
}) {
|
||||
const generalErrors = props.errors.nodes
|
||||
.filter(err => err.message.startsWith('[') === false)
|
||||
.map(err => err.message);
|
||||
const groupedServiceErrors = new Map<string, string[]>();
|
||||
const router = useRouteSelector();
|
||||
const { change } = props;
|
||||
|
||||
for (const err of props.errors.nodes) {
|
||||
if (err.message.startsWith('[')) {
|
||||
const [service, ...message] = err.message.split('] ');
|
||||
const serviceName = service.replace('[', '');
|
||||
const errorMessage = message.join('] ');
|
||||
|
||||
if (!groupedServiceErrors.has(serviceName)) {
|
||||
groupedServiceErrors.set(serviceName, [errorMessage]);
|
||||
}
|
||||
|
||||
groupedServiceErrors.get(serviceName)!.push(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
const serviceErrorEntries = Array.from(groupedServiceErrors.entries());
|
||||
|
||||
const breakingChanges = props.changes.nodes.filter(
|
||||
c => c.criticality === CriticalityLevel.Breaking,
|
||||
const metadata = useFragment(
|
||||
ChangesBlock_SchemaCheckConditionalBreakingChangeMetadataFragment,
|
||||
props.conditionBreakingChangeMetadata,
|
||||
);
|
||||
const dangerousChanges = props.changes.nodes.filter(
|
||||
c => c.criticality === CriticalityLevel.Dangerous,
|
||||
);
|
||||
const safeChanges = props.changes.nodes.filter(c => c.criticality === CriticalityLevel.Safe);
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div>
|
||||
{props.changes.total ? (
|
||||
<div>
|
||||
<div className="font-semibold">Schema Changes</div>
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger className="py-3 hover:no-underline">
|
||||
<div
|
||||
className={clsx(
|
||||
(!!change.approval && 'text-orange-500') ||
|
||||
(criticalityLevelMapping[change.criticality] ?? 'text-red-400'),
|
||||
)}
|
||||
>
|
||||
<div className="inline-flex justify-start space-x-2">
|
||||
<span className="text-gray-600 dark:text-white">{labelize(change.message)}</span>
|
||||
{change.isSafeBasedOnUsage && (
|
||||
<span className="cursor-pointer text-yellow-500">
|
||||
{' '}
|
||||
<CheckIcon className="inline size-3" /> Safe based on usage data
|
||||
</span>
|
||||
)}
|
||||
{change.usageStatistics && (
|
||||
<span className="flex items-center space-x-1 rounded-sm bg-gray-800 px-2 font-bold">
|
||||
<PulseIcon className="h-6 stroke-[1px]" />
|
||||
<span className="text-xs">
|
||||
{change.usageStatistics.topAffectedOperations.length}
|
||||
{change.usageStatistics.topAffectedOperations.length > 10 ? '+' : ''}{' '}
|
||||
{change.usageStatistics.topAffectedOperations.length === 1
|
||||
? 'operation'
|
||||
: 'operations'}{' '}
|
||||
by {change.usageStatistics.topAffectedClients.length}{' '}
|
||||
{change.usageStatistics.topAffectedClients.length === 1 ? 'client' : 'clients'}{' '}
|
||||
affected
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{change.approval ? (
|
||||
<div className="self-end">
|
||||
<ApprovedByBadge approval={change.approval} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-8 pt-4">
|
||||
{change.approval && <SchemaChangeApproval approval={change.approval} />}
|
||||
{change.usageStatistics && metadata ? (
|
||||
<div>
|
||||
<div className="space-y-3 p-6">
|
||||
{breakingChanges.length ? (
|
||||
<ChangesBlock changes={breakingChanges} criticality={CriticalityLevel.Breaking} />
|
||||
) : null}
|
||||
{dangerousChanges.length ? (
|
||||
<ChangesBlock
|
||||
changes={dangerousChanges}
|
||||
criticality={CriticalityLevel.Dangerous}
|
||||
/>
|
||||
) : null}
|
||||
{safeChanges.length ? (
|
||||
<ChangesBlock changes={safeChanges} criticality={CriticalityLevel.Safe} />
|
||||
) : null}
|
||||
<div className="flex space-x-4">
|
||||
<Table>
|
||||
<TableCaption>Top 10 affected operations.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[150px]">Operation Name</TableHead>
|
||||
<TableHead>Total Requests</TableHead>
|
||||
<TableHead className="text-right">% of traffic</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{change.usageStatistics.topAffectedOperations.map(
|
||||
({ hash, name, countFormatted, percentageFormatted }) => (
|
||||
<TableRow key={hash}>
|
||||
<TableCell className="font-medium">
|
||||
<Popover>
|
||||
<PopoverTrigger className="text-orange-500 hover:text-orange-500 hover:underline hover:underline-offset-4">
|
||||
{hash.substring(0, 4)}_{name}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="right">
|
||||
<div className="flex flex-col gap-y-2 text-sm">
|
||||
View live usage on
|
||||
{metadata.settings.targets.map(target =>
|
||||
target.target ? (
|
||||
<p>
|
||||
<Link
|
||||
className="text-orange-500 hover:text-orange-500"
|
||||
href={{
|
||||
pathname:
|
||||
'/[organizationId]/[projectId]/[targetId]/insights/[operationName]/[operationHash]',
|
||||
query: {
|
||||
organizationId: router.organizationId,
|
||||
projectId: router.projectId,
|
||||
targetId: target.target.cleanId,
|
||||
operationName: `${hash.substring(0, 4)}_${name}`,
|
||||
operationHash: hash,
|
||||
},
|
||||
}}
|
||||
target="_blank"
|
||||
>
|
||||
{target.name}
|
||||
</Link>{' '}
|
||||
<span className="text-white">target</span>
|
||||
</p>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
<PopoverArrow />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{countFormatted}</TableCell>
|
||||
<TableCell className="text-right">{percentageFormatted}</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Table>
|
||||
<TableCaption>Top 10 affected clients.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[150px]">Client Name</TableHead>
|
||||
<TableHead>Total Requests</TableHead>
|
||||
<TableHead className="text-right">% of traffic</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{change.usageStatistics.topAffectedClients.map(
|
||||
({ name, countFormatted, percentageFormatted }) => (
|
||||
<TableRow key={name}>
|
||||
<TableCell className="font-medium">{name}</TableCell>
|
||||
<TableCell className="text-right">{countFormatted}</TableCell>
|
||||
<TableCell className="text-right">{percentageFormatted}</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end pt-2 text-xs text-gray-100">
|
||||
{metadata && (
|
||||
<span>
|
||||
See{' '}
|
||||
{metadata.settings.targets.map((target, index, arr) => (
|
||||
<>
|
||||
{/* eslint-disable-next-line unicorn/no-negated-condition */}
|
||||
{!target.target ? (
|
||||
<TooltipProvider key={index}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>{target.name}</TooltipTrigger>
|
||||
<TooltipContent>Target does no longer exist.</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Link
|
||||
key={index}
|
||||
className="text-orange-500 hover:text-orange-500 "
|
||||
href={{
|
||||
pathname:
|
||||
'/[organizationId]/[projectId]/[targetId]/insights/schema-coordinate/[coordinate]',
|
||||
query: {
|
||||
organizationId: router.organizationId,
|
||||
projectId: router.projectId,
|
||||
targetId: target.target.cleanId,
|
||||
coordinate: change.path?.join('.'),
|
||||
},
|
||||
}}
|
||||
target="_blank"
|
||||
>
|
||||
{target.name}
|
||||
</Link>
|
||||
)}
|
||||
{index === arr.length - 1 ? null : index === arr.length - 2 ? 'and' : ','}
|
||||
</>
|
||||
))}{' '}
|
||||
target{metadata.settings.targets.length > 1 && 's'} insights for live usage
|
||||
data.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{props.errors.total ? (
|
||||
<div>
|
||||
<div className="font-semibold">Composition errors</div>
|
||||
<div className="space-y-3 p-6">
|
||||
{generalErrors.length ? (
|
||||
<ErrorsBlock title="Top-level errors" errors={generalErrors} />
|
||||
) : null}
|
||||
{serviceErrorEntries.length ? (
|
||||
<>
|
||||
{serviceErrorEntries.map(([service, errors]) => (
|
||||
<ErrorsBlock
|
||||
key={service}
|
||||
title={
|
||||
<>
|
||||
Errors from the <strong>"{service}"</strong> service
|
||||
</>
|
||||
}
|
||||
errors={errors}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : change.criticality === CriticalityLevel.Breaking ? (
|
||||
<>{change.criticalityReason ?? 'No details available for this breaking change.'}</>
|
||||
) : (
|
||||
<>No details available for this change.</>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovedByBadge(props: {
|
||||
approval: Exclude<SchemaChangeFieldsFragment['approval'], null | undefined>;
|
||||
}) {
|
||||
const approvalName = props.approval.approvedBy?.displayName ?? '<unknown>';
|
||||
|
||||
return (
|
||||
<span className="cursor-pointer text-green-500">
|
||||
<CheckIcon className="inline size-3" /> Approved by {approvalName}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SchemaChangeApproval(props: {
|
||||
approval: Exclude<SchemaChangeFieldsFragment['approval'], null | undefined>;
|
||||
}) {
|
||||
const approvalName = props.approval.approvedBy?.displayName ?? '<unknown>';
|
||||
const approvalDate = format(new Date(props.approval.approvedAt), 'do MMMM yyyy');
|
||||
const route = useRouteSelector();
|
||||
const schemaCheckPath =
|
||||
'/' +
|
||||
[
|
||||
route.organizationId,
|
||||
route.projectId,
|
||||
route.targetId,
|
||||
'checks',
|
||||
props.approval.schemaCheckId,
|
||||
].join('/');
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
This breaking change was manually{' '}
|
||||
{props.approval.schemaCheckId === route.schemaCheckId ? (
|
||||
<>
|
||||
{' '}
|
||||
approved by {approvalName} in this schema check on {approvalDate}.
|
||||
</>
|
||||
) : (
|
||||
<Link href={schemaCheckPath} className="text-orange-500 hover:underline">
|
||||
approved by {approvalName} on {approvalDate}.
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { cn } from '@/lib/utils';
|
|||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="w-full overflow-auto">
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
|
|
@ -32,7 +32,7 @@ const TableFooter = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn('bg-primary text-primary-foreground font-medium', className)}
|
||||
className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
|
@ -59,7 +59,7 @@ const TableHead = React.forwardRef<
|
|||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-muted-foreground h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0',
|
||||
'text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -73,7 +73,10 @@ const TableCell = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
className={cn(
|
||||
'p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -102,6 +102,19 @@ fragment SchemaChangeFields on SchemaChange {
|
|||
schemaCheckId
|
||||
}
|
||||
isSafeBasedOnUsage
|
||||
usageStatistics {
|
||||
topAffectedOperations {
|
||||
hash
|
||||
name
|
||||
countFormatted
|
||||
percentageFormatted
|
||||
}
|
||||
topAffectedClients {
|
||||
name
|
||||
countFormatted
|
||||
percentageFormatted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment TokenFields on Token {
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
import { VersionErrorsAndChanges } from '@/components/target/history/errors-and-changes';
|
||||
import { CriticalityLevel } from '@/graphql';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
const changes = [
|
||||
{
|
||||
message: 'Type "Foo" was removed',
|
||||
criticality: CriticalityLevel.Breaking,
|
||||
isSafeBasedOnUsage: false,
|
||||
},
|
||||
{
|
||||
message: 'Input field "limit" was added to input object type "Filter"',
|
||||
criticality: CriticalityLevel.Breaking,
|
||||
isSafeBasedOnUsage: false,
|
||||
},
|
||||
{
|
||||
message: 'Field "User.nickname" is no longer deprecated',
|
||||
criticality: CriticalityLevel.Dangerous,
|
||||
isSafeBasedOnUsage: false,
|
||||
},
|
||||
{
|
||||
message: 'Field "type" was added to object type "User"',
|
||||
criticality: CriticalityLevel.Safe,
|
||||
isSafeBasedOnUsage: false,
|
||||
},
|
||||
];
|
||||
|
||||
const errors = [
|
||||
{
|
||||
message: 'Field "Foo.id" can only be defined once.',
|
||||
},
|
||||
{
|
||||
message:
|
||||
'[subgraph-a] Foo.name -> is marked as @external but is not used by a @requires, @key, or @provides directive.',
|
||||
},
|
||||
{
|
||||
message:
|
||||
'[subgraph-b] Foo.name -> is marked as @external but is not used by a @requires, @key, or @provides directive.',
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<typeof VersionErrorsAndChanges> = {
|
||||
title: 'VersionErrorsAndChanges',
|
||||
component: VersionErrorsAndChanges,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof VersionErrorsAndChanges>;
|
||||
|
||||
export const Changes: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div className="dark">
|
||||
<VersionErrorsAndChanges
|
||||
changes={{
|
||||
nodes: changes,
|
||||
total: changes.length,
|
||||
}}
|
||||
errors={{
|
||||
nodes: [],
|
||||
total: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Errors: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<VersionErrorsAndChanges
|
||||
errors={{
|
||||
nodes: errors,
|
||||
total: errors.length,
|
||||
}}
|
||||
changes={{
|
||||
nodes: [],
|
||||
total: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Both: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<VersionErrorsAndChanges
|
||||
errors={{
|
||||
nodes: errors,
|
||||
total: errors.length,
|
||||
}}
|
||||
changes={{
|
||||
nodes: changes,
|
||||
total: changes.length,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -327,8 +327,8 @@ importers:
|
|||
specifier: 0.2.3
|
||||
version: link:../core/dist
|
||||
'@graphql-inspector/core':
|
||||
specifier: 5.0.2
|
||||
version: 5.0.2(graphql@16.8.1)
|
||||
specifier: 5.1.0-alpha-20231208113249-34700c8a
|
||||
version: 5.1.0-alpha-20231208113249-34700c8a(graphql@16.8.1)
|
||||
'@graphql-tools/code-file-loader':
|
||||
specifier: ~8.1.0
|
||||
version: 8.1.0(graphql@16.8.1)
|
||||
|
|
@ -1158,8 +1158,8 @@ importers:
|
|||
packages/services/storage:
|
||||
devDependencies:
|
||||
'@graphql-inspector/core':
|
||||
specifier: 5.0.2
|
||||
version: 5.0.2(graphql@16.8.1)
|
||||
specifier: 5.1.0-alpha-20231208113249-34700c8a
|
||||
version: 5.1.0-alpha-20231208113249-34700c8a(graphql@16.8.1)
|
||||
'@sentry/node':
|
||||
specifier: 7.102.1
|
||||
version: 7.102.1
|
||||
|
|
@ -6609,6 +6609,7 @@ packages:
|
|||
graphql: 16.8.1
|
||||
object-inspect: 1.12.3
|
||||
tslib: 2.6.2
|
||||
dev: true
|
||||
|
||||
/@graphql-inspector/core@5.1.0-alpha-20231208113249-34700c8a(graphql@16.8.1):
|
||||
resolution: {integrity: sha512-vzJEhQsZz+suo8T32o9dJNOa42IcLHfvmBm3EqRuMKQ0PU8KintUdRG1kFd6NFvNnpPHOVWFc+PYgFKCm7mPoA==}
|
||||
|
|
@ -6620,7 +6621,6 @@ packages:
|
|||
graphql: 16.8.1
|
||||
object-inspect: 1.12.3
|
||||
tslib: 2.6.2
|
||||
dev: true
|
||||
|
||||
/@graphql-inspector/coverage-command@5.0.3(@graphql-inspector/config@4.0.2)(@graphql-inspector/loaders@4.0.3)(graphql@16.8.1)(yargs@17.7.2):
|
||||
resolution: {integrity: sha512-LeAsn9+LjyxCzRnDvcfnQT6I0cI8UWnjPIxDkHNlkJLB0YWUTD1Z73fpRdw+l2kbYgeoMLFOK8TmilJjFN1+qQ==}
|
||||
|
|
|
|||
Loading…
Reference in a new issue