diff --git a/integration-tests/tests/api/proposals/change-tracking.spec.ts b/integration-tests/tests/api/proposals/change-tracking.spec.ts new file mode 100644 index 00000000000..7eb02c17b60 --- /dev/null +++ b/integration-tests/tests/api/proposals/change-tracking.spec.ts @@ -0,0 +1,149 @@ +import { pollFor } from 'testkit/flow'; +import { graphql } from 'testkit/gql'; +import { ProjectType, ResourceAssignmentModeType } from 'testkit/gql/graphql'; +import { execute } from 'testkit/graphql'; +import { initSeed } from 'testkit/seed'; +import { approveProposal, checkSchema, CreateProposalMutation } from './operations'; + +describe('Schema Proposals', () => { + test.concurrent( + 'approved a schema proposal changes are flagged by schema checks', + async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag } = await createOrg(); + await setFeatureFlag('schemaProposals', true); + const { target, createTargetAccessToken } = await createProject(ProjectType.Federation); + + const result = await execute({ + document: CreateProposalMutation, + variables: { + input: { + target: { byId: target.id }, + author: 'Jeff', + title: 'Proposed changes to the schema...', + }, + }, + authToken: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + + const proposalId = result.createSchemaProposal.ok?.schemaProposal.id!; + expect(proposalId).toBeDefined(); + + const service = 'sample'; + const sdl = ` + type Query { + example(id: ID!): Example + } + + type Example { + id: ID! + name: String! + } + `; + const writeToken = await createTargetAccessToken({ + mode: 'readWrite', + }); + await writeToken + .publishSchema({ + sdl, + service, + url: 'http://localocalhost:4444/example', + }) + .then(r => r.expectNoGraphQLErrors()); + + const modifiedSdl = ` + type Query { + example(id: ID!): Example + } + + type Example { + id: ID! + name: String! + } + + extend type Example { + preview: String + } + `; + + // add proposal check + const { schemaCheck: proposedSchema } = await checkSchema({ + accessKey: ownerToken, + input: { + sdl: modifiedSdl, + schemaProposalId: proposalId, + service, + target: { byId: target.id }, + url: 'http://localocalhost:4444/example', + }, + }).then(r => r.expectNoGraphQLErrors()); + expect(proposedSchema.__typename).toBe('SchemaCheckSuccess'); + expect( + proposedSchema.__typename === 'SchemaCheckSuccess' && proposedSchema.changes?.total, + ).toBe(1); + + // approve the schema proposal + await approveProposal({ + proposalId, + service, + accessKey: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + + // run another check that implements the proposal (no schemaProposalId passed as an argument) + const { schemaCheck } = await checkSchema({ + accessKey: ownerToken, + input: { + sdl: modifiedSdl, + service: 'example', + target: { byId: target.id }, + url: 'http://localocalhost:4444/example', + }, + }).then(r => r.expectNoGraphQLErrors()); + + const checkId = + schemaCheck.__typename === 'SchemaCheckSuccess' && schemaCheck.schemaCheck?.id!; + expect(checkId).toBeDefined(); + + const ReadCheckQuery = graphql(` + query ReadCheck($targetId: ID!, $checkId: ID!) { + target(reference: { byId: $targetId }) { + schemaCheck(id: $checkId) { + id + schemaChanges { + edges { + node { + schemaProposalChangeDetails { + implementedBy { + id + } + schemaProposal { + id + } + } + } + } + } + } + } + } + `); + + // schema check implementation tracking is an async process, so wait for the job to process + const poll = pollFor(async () => { + const { target: t } = await execute({ + document: ReadCheckQuery, + variables: { + checkId: String(checkId), + targetId: target.id, + }, + token: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + const changeImplementsProposalId = + t?.schemaCheck?.schemaChanges?.edges[0].node.schemaProposalChangeDetails?.schemaProposal + .id; + return changeImplementsProposalId === proposalId; + }); + await expect(poll).resolves.toBeUndefined(); + }, + ); +}); diff --git a/integration-tests/tests/api/proposals/operations.ts b/integration-tests/tests/api/proposals/operations.ts new file mode 100644 index 00000000000..af8f1902d6c --- /dev/null +++ b/integration-tests/tests/api/proposals/operations.ts @@ -0,0 +1,129 @@ +/** Common useful operations */ +import { graphql } from 'testkit/gql'; +import { + ApproveProposalMutationVariables, + CheckSchemaMutationVariables, + ReadProposalQueryVariables, +} from 'testkit/gql/graphql'; +import { execute } from 'testkit/graphql'; + +export const CreateProposalMutation = graphql(` + mutation CreateProposal($input: CreateSchemaProposalInput!) { + createSchemaProposal(input: $input) { + ok { + schemaProposal { + id + } + } + error { + message + } + } + } +`); + +export const ReadProposalQuery = graphql(` + query ReadProposal($input: SchemaProposalInput!) { + schemaProposal(input: $input) { + title + description + checks(input: { latestPerService: true }) { + edges { + node { + id + } + } + } + } + } +`); + +export const CheckSchemaMutation = graphql(` + mutation CheckSchema($input: SchemaCheckInput!) { + schemaCheck(input: $input) { + ... on SchemaCheckSuccess { + __typename + valid + changes { + nodes { + message + criticality + } + total + } + schemaCheck { + id + } + } + ... on SchemaCheckError { + __typename + valid + changes { + nodes { + message + criticality + } + total + } + errors { + nodes { + message + } + total + } + schemaCheck { + id + } + } + } + } +`); + +export const ApproveProposalMutation = graphql(` + mutation ApproveProposal($proposalId: ID!, $service: String! = "") { + reviewSchemaProposal( + input: { schemaProposalId: $proposalId, serviceName: $service, stageTransition: APPROVED } + ) { + ok { + __typename + } + error { + message + } + } + } +`); + +export function readProposal({ + accessKey, + ...vars +}: ReadProposalQueryVariables & { accessKey: string }) { + return execute({ + document: ReadProposalQuery, + variables: vars, + token: accessKey, + }); +} + +/** Requires schemaProposal:modify, schemaProposal:describe, project:describe, and schemaCheck:create */ +export function checkSchema({ + accessKey, + ...vars +}: CheckSchemaMutationVariables & { accessKey: string }) { + return execute({ + document: CheckSchemaMutation, + variables: vars, + token: accessKey, + }); +} + +export function approveProposal({ + accessKey, + ...vars +}: ApproveProposalMutationVariables & { accessKey: string }) { + return execute({ + document: ApproveProposalMutation, + variables: vars, + token: accessKey, + }); +} diff --git a/integration-tests/tests/api/proposals/read.spec.ts b/integration-tests/tests/api/proposals/read.spec.ts index 20721d7b5c3..c0dfd36c666 100644 --- a/integration-tests/tests/api/proposals/read.spec.ts +++ b/integration-tests/tests/api/proposals/read.spec.ts @@ -1,45 +1,12 @@ -import { graphql } from 'testkit/gql'; import { ProjectType, ResourceAssignmentModeType } from 'testkit/gql/graphql'; import { execute } from 'testkit/graphql'; import { initSeed } from 'testkit/seed'; - -const CreateProposalMutation = graphql(` - mutation CreateProposalMutation($input: CreateSchemaProposalInput!) { - createSchemaProposal(input: $input) { - ok { - schemaProposal { - id - } - } - error { - message - } - } - } -`); - -const ReadProposalQuery = graphql(` - query ReadProposalQuery($input: SchemaProposalInput!) { - schemaProposal(input: $input) { - title - description - checks(input: { latestPerService: true }) { - edges { - node { - id - } - } - } - } - } -`); +import { CreateProposalMutation, readProposal, ReadProposalQuery } from './operations'; /** * Creates a proposal and returns a token with specified permissions **/ -async function setup(input: { - tokenPermissions: string[]; -}): Promise<{ accessKey: string; proposalId: string }> { +async function setup(input: { tokenPermissions: string[] }) { const { createOrg, ownerToken } = await initSeed().createOwner(); const { createProject, createOrganizationAccessToken, setFeatureFlag } = await createOrg(); await setFeatureFlag('schemaProposals', true); @@ -68,26 +35,23 @@ async function setup(input: { ownerToken, ); const proposalId = result.createSchemaProposal.ok?.schemaProposal.id!; - return { accessKey, proposalId }; + return { accessKey, proposalId, target }; } describe('Schema Proposals', () => { test.concurrent( 'can read proposal with "schemaProposal:describe" permission', async ({ expect }) => { - const { accessKey, proposalId } = await setup({ + const { accessKey, proposalId, target } = await setup({ tokenPermissions: ['schemaProposal:describe'], }); { - const proposal = await execute({ - document: ReadProposalQuery, - variables: { - input: { - id: proposalId, - }, + const proposal = await readProposal({ + input: { + id: proposalId, }, - token: accessKey, + accessKey, }).then(r => r.expectNoGraphQLErrors()); expect(proposal.schemaProposal?.title).toMatchInlineSnapshot( diff --git a/integration-tests/tests/api/proposals/subscribe.spec.ts b/integration-tests/tests/api/proposals/subscribe.spec.ts index 33be6f41cdd..564017adb7f 100644 --- a/integration-tests/tests/api/proposals/subscribe.spec.ts +++ b/integration-tests/tests/api/proposals/subscribe.spec.ts @@ -2,21 +2,7 @@ import { graphql } from 'testkit/gql'; import { ProjectType, ResourceAssignmentModeType } from 'testkit/gql/graphql'; import { execute, subscribe } from 'testkit/graphql'; import { initSeed } from 'testkit/seed'; - -const CreateProposalMutation = graphql(` - mutation CreateProposalMutation($input: CreateSchemaProposalInput!) { - createSchemaProposal(input: $input) { - ok { - schemaProposal { - id - } - } - error { - message - } - } - } -`); +import { checkSchema, CreateProposalMutation } from './operations'; const ProposalCompositionSubscription = graphql(` subscription ProposalCompositionSubscription( @@ -61,14 +47,14 @@ async function setup(input: { tokenPermissions: string[] }) { ownerToken, ); const proposalId = result.createSchemaProposal.ok?.schemaProposal.id!; - return { accessKey, proposalId, project }; + return { ownerToken, accessKey, proposalId, project, targetId: project.target.id }; } describe('Schema Proposals', () => { test.concurrent( 'can subscribe for proposal events with "schemaProposal:describe" permission', async ({ expect }) => { - const { accessKey, proposalId, project } = await setup({ + const { accessKey, proposalId, project, targetId, ownerToken } = await setup({ tokenPermissions: ['schemaProposal:describe'], }); @@ -93,20 +79,23 @@ describe('Schema Proposals', () => { service: 'example', url: 'http://localhost:4001', }); - const checkResultErrors = await token - .checkSchema( - /* GraphQL */ ` + const checkResultErrors = await checkSchema({ + input: { + sdl: /* GraphQL */ ` type Query { ping: String pong: String } `, - 'example', - undefined, - undefined, - proposalId, - ) - .then(r => r.expectNoGraphQLErrors()); + service: 'example', + schemaProposalId: proposalId, + target: { + byId: targetId, + }, + }, + accessKey: ownerToken, // use owner token here because we need schemaProposal:modify + }).then(r => r.expectNoGraphQLErrors()); + expect(checkResultErrors.schemaCheck.__typename).toBe(`SchemaCheckSuccess`); const { value } = await query.next(); expect(value.data.schemaProposalComposition.status).toBe(`SUCCESS`); diff --git a/packages/migrations/src/actions/2026.03.28T00-00-00.proposal-change-tracking.ts b/packages/migrations/src/actions/2026.03.28T00-00-00.proposal-change-tracking.ts new file mode 100644 index 00000000000..2957a5286bb --- /dev/null +++ b/packages/migrations/src/actions/2026.03.28T00-00-00.proposal-change-tracking.ts @@ -0,0 +1,43 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +export default { + name: '2026.03.28T00-00-00.proposal-change-tracking.ts', + run: ({ psql }) => psql` + CREATE TABLE IF NOT EXISTS "proposal_approved_changes" ( + "id" UUID PRIMARY KEY DEFAULT uuid_generate_v4 (), + + -- hash of the metadata. Because this is a hash, it has a slim + -- chance of conflicting. This is why we need the "change" to perform a more + -- exact match using the "@graphql-inspector/compare-changes" package + "hash" TEXT NOT NULL, + + -- this is the exact change + "change" JSONB NOT NULL, + + "proposal_id" UUID NOT NULL + REFERENCES "schema_proposals"("id"), + + -- This is the version in which the approved change has been implemented. + -- It is for linking the change to the schema history + "schema_version_id" UUID + REFERENCES "schema_versions"("id"), + + "service" TEXT, + "target_id" UUID NOT NULL + REFERENCES "targets"("id") + ON DELETE CASCADE + ); + + -- partial index used on publish to look up if a change is approved and not implemented + CREATE INDEX "target_change_not_implemented" ON "proposal_approved_changes" ("target_id", "hash") + WHERE "schema_version_id" IS NULL; + + -- look up on a proposal, which changes have been implemented + -- and where they were implemented + CREATE INDEX "proposal_change_implemented" ON "proposal_approved_changes" ("target_id", "proposal_id") + WHERE "schema_version_id" IS NOT NULL; + + -- look up on the schema history page, which proposal the change belonged to + CREATE INDEX "schema_implements_changes" ON "proposal_approved_changes" ("target_id", "schema_version_id"); + `, +} satisfies MigrationExecutor; diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index 7e8d35806d5..a1f3744fbe3 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -184,5 +184,6 @@ export const runPGMigrations = async (args: { slonik: PostgresDatabasePool; runT await import('./actions/2026.02.24T00-00-00.proposal-composition'), await import('./actions/2026.02.25T00-00-00.oidc-integration-domains'), await import('./actions/2026.03.25T00-00-00.access-token-expiration'), + await import('./actions/2026.03.28T00-00-00.proposal-change-tracking'), ], }); diff --git a/packages/services/api/package.json b/packages/services/api/package.json index 03ec91bd477..3d7d5e43cdd 100644 --- a/packages/services/api/package.json +++ b/packages/services/api/package.json @@ -23,6 +23,7 @@ "@date-fns/utc": "2.1.1", "@graphql-hive/core": "workspace:*", "@graphql-hive/signal": "1.0.0", + "@graphql-inspector/compare-changes": "0.1.0-alpha-20260410200850-4134db734dce207751762bfcbe74ab87eab30fe9", "@graphql-inspector/core": "7.1.2", "@graphql-tools/merge": "9.1.1", "@hive/cdn-script": "workspace:*", diff --git a/packages/services/api/src/modules/proposals/module.graphql.mappers.ts b/packages/services/api/src/modules/proposals/module.graphql.mappers.ts new file mode 100644 index 00000000000..27c33277fe1 --- /dev/null +++ b/packages/services/api/src/modules/proposals/module.graphql.mappers.ts @@ -0,0 +1,7 @@ +export type SchemaProposalChangeDetailsMapper = + | { + schemaProposal: { id: string }; + implementedBy?: { id: string } | null; + } + | null + | undefined; diff --git a/packages/services/api/src/modules/proposals/module.graphql.ts b/packages/services/api/src/modules/proposals/module.graphql.ts index c9168f204c6..2b5e191a3c6 100644 --- a/packages/services/api/src/modules/proposals/module.graphql.ts +++ b/packages/services/api/src/modules/proposals/module.graphql.ts @@ -331,12 +331,6 @@ export default gql` """ serviceName: String! - # @todo - # """ - # The specific version of the proposal that this review is for. - # """ - # schemaProposalVersion: SchemaProposalVersion - """ If null then this review is just a comment. Otherwise, the reviewer changed the state of the proposal as part of their review. E.g. The reviewer can approve a version with a comment. @@ -375,6 +369,26 @@ export default gql` extend type SchemaChange { meta: SchemaChangeMeta + + """ + If this change was proposed by a schema proposal, then this returns + details about that proposal and where the change was implemented. + """ + schemaProposalChangeDetails: SchemaProposalChangeDetails + } + + type SchemaProposalChangeDetails { + """ + If this schema change is associated with a proposal, this returns that proposal + This is useful for matching changes from a schema check or schema history/version to a proposal. + """ + schemaProposal: SchemaProposal! + + """ + If this change is for a schema proposal, this is the schema version + (viewable in the History) where the change was implemented. + """ + implementedBy: SchemaVersion } union SchemaChangeMeta = diff --git a/packages/services/api/src/modules/proposals/providers/schema-proposal-manager.ts b/packages/services/api/src/modules/proposals/providers/schema-proposal-manager.ts index 34de9ac96d3..b2f3fc27c98 100644 --- a/packages/services/api/src/modules/proposals/providers/schema-proposal-manager.ts +++ b/packages/services/api/src/modules/proposals/providers/schema-proposal-manager.ts @@ -1,10 +1,17 @@ /** * This wraps the higher level logic with schema proposals. */ +import { SchemaProposalChangeDetailsMapper } from '../module.graphql.mappers'; +import DataLoader from 'dataloader'; import { Inject, Injectable, Scope } from 'graphql-modules'; -import { TargetReferenceInput } from 'packages/libraries/core/src/client/__generated__/types'; +import type { TargetReferenceInput } from 'packages/libraries/core/src/client/__generated__/types'; +import { isChangeEqual } from '@graphql-inspector/compare-changes'; +import { Change } from '@graphql-inspector/core'; +import { traceFn } from '@hive/service-common'; +import { SchemaChangeType } from '@hive/storage'; import type { SchemaProposalStage } from '../../../__generated__/types'; import { HiveError } from '../../../shared/errors'; +import { cache } from '../../../shared/helpers'; import { Session } from '../../auth/lib/authz'; import { IdTranslator } from '../../shared/providers/id-translator'; import { Logger } from '../../shared/providers/logger'; @@ -12,11 +19,20 @@ import { PUB_SUB_CONFIG, type HivePubSub } from '../../shared/providers/pub-sub' import { Storage } from '../../shared/providers/storage'; import { SchemaProposalStorage } from './schema-proposal-storage'; +type ChangeSelector = { + targetId: string; + change: SchemaChangeType; +}; + @Injectable({ scope: Scope.Operation, }) export class SchemaProposalManager { private logger: Logger; + private approvedChangeProposalLoader: DataLoader< + ChangeSelector, + SchemaProposalChangeDetailsMapper + >; constructor( logger: Logger, @@ -27,6 +43,69 @@ export class SchemaProposalManager { @Inject(PUB_SUB_CONFIG) private pubSub: HivePubSub, ) { this.logger = logger.child({ source: 'SchemaProposalsManager' }); + + const cacheKeyFn = function (selector: ChangeSelector) { + return `${selector.targetId}/${selector.change.id}`; + }; + this.approvedChangeProposalLoader = new DataLoader( + async selectors => { + // map the list of keys in the order in which they are passed in so it can + // be mapped back to. + const keys = selectors.map(cacheKeyFn); + + // combine selectors by targetId + const groupedSelectors = selectors.reduce((groups, selector) => { + const group = groups.get(selector.targetId); + if (!group) { + groups.set(selector.targetId, [selector.change]); + } else { + group.push(selector.change); + } + return groups; + }, new Map()); + + // fetch the groups + const approvedProposedChangesByCacheId = await Promise.all( + Array.from(groupedSelectors.entries()).map(async ([targetId, changes]) => { + const result = await this.proposalStorage.getEquivalentUnimplementedApprovedChanges({ + changes: changes as unknown as Change[], + targetId, + }); + const entries = result + .map((apc, i) => { + if (apc) { + return [cacheKeyFn({ change: changes[i], targetId }), apc] as const; + } + }) + .filter(e => e !== undefined); + return Object.fromEntries(entries); + }), + ); + + // map back to the original selectors + return keys.map((cacheKey): SchemaProposalChangeDetailsMapper => { + for (const targetChangesByCacheId of approvedProposedChangesByCacheId) { + const approvedRecord = targetChangesByCacheId[cacheKey]; + if (approvedRecord) { + return { + schemaProposal: { + id: approvedRecord.proposalId, + }, + implementedBy: approvedRecord.schemaVersionId + ? { + id: approvedRecord.schemaVersionId, + } + : null, + }; + } + } + return null; + }); + }, + { + cacheKeyFn, + }, + ); } async subscribeToSchemaProposalCompositions(args: { proposalId: string }) { @@ -116,6 +195,7 @@ export class SchemaProposalManager { }; } + @cache<{ id: string }>(({ id }) => id) async getProposal(args: { id: string }) { const proposal = await this.proposalStorage.getProposal(args); @@ -255,4 +335,152 @@ export class SchemaProposalManager { throw new HiveError('Not implemented'); } + + @traceFn('SchemaProposalManager._getImplementedVersionsByProposalId', { + initAttributes: input => ({ + 'hive.schema_proposal.id': input.schemaProposalId || '', + 'hive.target.id': input.targetId, + }), + resultAttributes: result => ({ + 'hive.proposals.details.length': result?.length || 0, + }), + }) + @cache<{ + schemaProposalId: string; + targetId: string; + }>(({ schemaProposalId, targetId }) => `${targetId}/${schemaProposalId}`) + async _getImplementedVersionsByProposalId(args: { schemaProposalId: string; targetId: string }) { + return this.proposalStorage.getImplementedVersionsByProposalId({ + schemaProposalId: args.schemaProposalId, + targetId: args.targetId, + }); + } + + @traceFn('SchemaProposalManager._getImplementedVersionsBySchemaVersionId', { + initAttributes: input => ({ + 'hive.schema_proposal.id': input.schemaVersionId, + 'hive.target.id': input.targetId, + }), + resultAttributes: result => ({ + 'hive.proposals.details.length': result?.length || 0, + }), + }) + @cache<{ + schemaVersionId: string; + targetId: string; + }>(({ schemaVersionId, targetId }) => `${targetId}/${schemaVersionId}`) + async _getImplementedVersionsBySchemaVersionId(args: { + schemaVersionId: string; + targetId: string; + }) { + return this.proposalStorage.getImplementedApprovedChangesByVersionId({ + schemaVersionId: args.schemaVersionId, + targetId: args.targetId, + }); + } + + /** + * If dealing with a non-schema proposal related check or schema version, + * then this function can search to find if any of the check's or historic changes + * are matches for approved schema proposal changes. + * + * Note that if you need the ChangeDetails object for a schema proposal's changes, then use + * `getProposalChangeDetails`. + **/ + async getMatchingApprovedProposalChangeDetails({ + targetId, + change, + }: { + targetId: string; + change: SchemaChangeType; + }) { + return this.approvedChangeProposalLoader.load({ targetId, change }); + } + + /** + * Schema proposals use checks under the hood which makes this a bit confusing. + * This function is for finding `proposalChangeDetails` for a schema proposal's + * associated check's changes -- effectively the proposed changes. + */ + async getProposalChangeDetails({ + schemaProposalId, + targetId, + change, + }: { + targetId: string; + + // The ID of the schema proposal that is proposing this change + schemaProposalId: string; + + // The change record. Used to compare against the returned implemented change + // to verify that they are equal. + change: SchemaChangeType; + }) { + // `_getImplementedVersionsByProposalId` is cached so it's safe to call multiple times. + // This is intended to be used by the SchemaChange resolver and would be called on every + // SchemaChange. + const implementedChanges = await this._getImplementedVersionsByProposalId({ + schemaProposalId, + targetId, + }); + + // Verify which versions implement the associated changes using an exact compare + const implementation = implementedChanges.find( + implementedChange => + isChangeEqual( + implementedChange.change as unknown as Change, + change as unknown as Change, + ) && implementedChange.schemaVersionId, + ); + + return { + schemaProposal: { id: schemaProposalId }, + implementedBy: implementation?.schemaVersionId + ? { + id: implementation.schemaVersionId, + } + : null, + }; + } + + async getImplementedVersionsBySchemaVersionId({ + targetId, + schemaVersionId, + change, + }: { + targetId: string; + schemaVersionId: string; + + // The change record. Used to compare against the returned implemented change + // to verify that they are equal. + change: SchemaChangeType; + }) { + // `_getImplementedVersionsBySchemaVersionId` is cached so it's safe to call multiple times. + // This is intended to be used by the SchemaChange resolver and would be called on every + // SchemaChange. + const implementedChanges = await this._getImplementedVersionsBySchemaVersionId({ + targetId, + schemaVersionId, + }); + + // Verify which versions implement the associated changes using an exact compare + const implementation = implementedChanges.find( + ( + implementedChange, + ): implementedChange is typeof implementedChange & { schemaVersionId: string } => + isChangeEqual( + implementedChange.change as unknown as Change, + change as unknown as Change, + ) && !!implementedChange.schemaVersionId, + ); + if (implementation) { + return { + schemaProposal: { id: implementation.proposalId }, + implementedBy: { + id: implementation.schemaVersionId, + }, + }; + } + return null; + } } diff --git a/packages/services/api/src/modules/proposals/providers/schema-proposal-storage.ts b/packages/services/api/src/modules/proposals/providers/schema-proposal-storage.ts index 8faafdb0936..ff3d6e576c2 100644 --- a/packages/services/api/src/modules/proposals/providers/schema-proposal-storage.ts +++ b/packages/services/api/src/modules/proposals/providers/schema-proposal-storage.ts @@ -3,13 +3,18 @@ */ import { Inject, Injectable, Scope } from 'graphql-modules'; import { z } from 'zod'; -import { PostgresDatabasePool, psql } from '@hive/postgres'; +import { generateChangeHash, isChangeEqual } from '@graphql-inspector/compare-changes'; +import { Change } from '@graphql-inspector/core'; +import { CommonQueryMethods, PostgresDatabasePool, psql } from '@hive/postgres'; import { decodeCreatedAtAndUUIDIdBasedCursor, encodeCreatedAtAndUUIDIdBasedCursor, + SchemaChangeModel, } from '@hive/storage'; import { TaskScheduler } from '@hive/workflows/kit'; +import { SchemaProposalApprovalTask } from '@hive/workflows/tasks/schema-proposal-approval'; import { SchemaProposalCompositionTask } from '@hive/workflows/tasks/schema-proposal-composition'; +import { SchemaProposalImplementationTask } from '@hive/workflows/tasks/schema-proposal-implementation'; import { SchemaProposalStage } from '../../../__generated__/types'; import { Logger } from '../../shared/providers/logger'; import { Storage } from '../../shared/providers/storage'; @@ -58,6 +63,24 @@ export class SchemaProposalStorage { await this.taskScheduler.scheduleTask(SchemaProposalCompositionTask, input); } + async runBackgroundApproval(input: { proposalId: string; targetId: string }) { + await this.taskScheduler.scheduleTask(SchemaProposalApprovalTask, input); + } + + /** + * Kicks off a job to check and update all changes that are part of the schema version, to flag them as + * implemented if they represent an approved schema change + */ + async runBackgroundImplementationTracker( + input: { + targetId: string; + schemaVersionId: string; + }, + conn: CommonQueryMethods = this.pool, + ) { + await this.taskScheduler.scheduleTask(SchemaProposalImplementationTask, input, { trx: conn }); + } + private async assertSchemaProposalsEnabled(args: { organizationId: string; targetId: string; @@ -125,6 +148,7 @@ export class SchemaProposalStorage { WHERE "id" = ${args.id} AND "stage" <> 'IMPLEMENTED' `, ); + const row = await conn.maybeOne(psql` INSERT INTO schema_proposal_reviews ("schema_proposal_id", "stage_transition", "author", "service_name") @@ -136,9 +160,15 @@ export class SchemaProposalStorage { ) RETURNING ${schemaProposalReviewFields} `); + return SchemaProposalReviewModel.parse(row); }); + // @todo rollback if this fails + if (args.stage === 'APPROVED') { + await this.runBackgroundApproval({ proposalId: args.id, targetId: args.targetId }); + } + return { type: 'ok' as const, review, @@ -193,7 +223,7 @@ export class SchemaProposalStorage { , ${args.stage} , ${args.author} ) - RETURNING ${schemaProposalFields} + RETURNING ${schemaProposalFields(psql`sp`)} `, ) .then(row => SchemaProposalModel.parse(row)); @@ -233,7 +263,7 @@ export class SchemaProposalStorage { .maybeOne( psql` SELECT - ${schemaProposalFields} + ${schemaProposalFields(psql`sp`)} FROM "schema_proposals" AS "sp" WHERE @@ -268,7 +298,7 @@ export class SchemaProposalStorage { ); const result = await this.pool.any(psql` SELECT - ${schemaProposalFields} + ${schemaProposalFields(psql`sp`)} FROM "schema_proposals" as "sp" WHERE @@ -379,20 +409,164 @@ export class SchemaProposalStorage { }, }; } + + /** + * Updates an approved change to set the schema version where it has been implemented. + */ + async implementApprovedChange(args: { + // the approved change's ID + id: string; + targetId: string; + // the schema version where this change has been implemented + implementingSchemaVersionId: string; + }) { + this.logger.debug( + 'Marking approved change as implemented by schema version (id=%s, target=%s, schema_version=%s)', + args.id, + args.targetId, + args.implementingSchemaVersionId, + ); + await this.pool.query(psql` + UPDATE "proposal_approved_changes" + SET "schema_version_id" = ${args.implementingSchemaVersionId} + WHERE id = ${args.id} + AND target_id = ${args.targetId} + AND schema_version_id IS NULL + `); + } + + /** + * This looks up the change approval records for a schema proposal, and finds + * where they have been implemented in a schema version. + */ + async getImplementedVersionsByProposalId(args: { schemaProposalId: string; targetId: string }) { + // fetch the changes based on the hash. Note that ordering is not guaranteed here + const implementedChanges = await this.pool + .any( + psql` + SELECT ${proposalApprovedChangeFields(psql`"c"`)} + FROM "proposal_approved_changes" as "c" + WHERE proposal_id = ${args.schemaProposalId} + AND target_id = ${args.targetId} + AND schema_version_id IS NOT NULL + `, + ) + .then(result => result.map(row => ProposalApprovedChangeModel.parse(row))); + return implementedChanges; + } + + /** + * This looks up the change approval record for an existing schema version. + * This is used on the schema history page to show which proposal each change + * belongs to + */ + async getImplementedApprovedChangesByVersionId(args: { + schemaVersionId: string; + targetId: string; + }) { + // fetch the changes based on the hash. Note that ordering is not guaranteed here + const implementedChanges = await this.pool + .any( + psql` + SELECT ${proposalApprovedChangeFields(psql`"c"`)} + FROM proposal_approved_changes as "c" + WHERE schema_version_id = ${args.schemaVersionId} + AND target_id = ${args.targetId} + `, + ) + .then(result => result.map(row => ProposalApprovedChangeModel.parse(row))); + return implementedChanges; + } + + /** + * If we have a set of changes, this looks up to determine whether or not those changes + * have been approved. This is useful for looking up (on check or publish) whether the + * change matches any of the approved proposals' changess, because in this situation + * there is no known proposal or schema version to match on. This function queries for all + * passed in changes, so any batching/pagination should be done prior to calling this function. + * + * This is also ran for the schema checks page to display whether or not a change has + * been approved by a proposal or not. + * + * @returns An array of change approval records. The index corresponds with the original + * changes array passed. + */ + async getEquivalentUnimplementedApprovedChanges( + args: { changes: Change[]; targetId: string }, + conn: CommonQueryMethods = this.pool, + ) { + // calculate hashes the same order as the changes + const hashes = args.changes.map(generateChangeHash); + + // fetch the changes based on the hash. Note that ordering is not guaranteed here + const result = await conn.any(psql` + SELECT ${proposalApprovedChangeFields(psql`"c"`)} + FROM "proposal_approved_changes" as "c" + WHERE hash = ANY(${psql.array(hashes, 'text')}) + AND target_id = ${args.targetId} + AND schema_version_id IS NULL + `); + + const approvedChanges = result.map(row => ProposalApprovedChangeModel.parse(row)); + const approvedChangeLookup = Map.groupBy(approvedChanges, c => c.hash); + + // iterate over all changes and hashes, determine if this change is within the approvedChanges list + return args.changes.map((change, i) => { + const hash = hashes[i]; + const approvedMatch = approvedChangeLookup.get(hash); + const changeApproval = + approvedMatch?.find(match => { + return isChangeEqual(match.change as Change, change); + }) ?? null; + + return changeApproval; + }); + } } -const schemaProposalFields = psql` - sp."id" - , to_json(sp."created_at") as "createdAt" - , to_json(sp."updated_at") as "updatedAt" - , sp."title" - , sp."description" - , sp."stage" - , sp."target_id" as "targetId" - , sp."author" - , sp."composition_status" as "compositionStatus" - , to_json(sp."composition_timestamp") as "compositionTimestamp" - , sp."composition_status_reason" as "compositionStatusReason" +// @todo dedupe from the implementation workflow +const ProposalApprovedChangeModel = z + .object({ + id: z.string(), + // the type and path of the change is baked into the hash, so it's safe + hash: z.string(), + change: SchemaChangeModel, + proposalId: z.string(), + schemaVersionId: z.string().nullable(), + service: z.string().nullable(), + targetId: z.string(), + }) + .transform(data => ({ + ...data, + change: { + // add back the path since that doesn't get saved to the db except for in the hash + path: data.hash.split(':')[1] as string | undefined, + ...data.change, + }, + })); + +const proposalApprovedChangeFields = (prefix = psql`"proposal_approved_changes"`) => psql` + ${prefix}."id" + ,${prefix}."hash" + ,${prefix}."change" + ,${prefix}."proposal_id" as "proposalId" + ,${prefix}."schema_version_id" as "schemaVersionId" + ,${prefix}."service" + ,${prefix}."target_id" as "targetId" +`; + +const schemaProposalFields = (prefix = psql`"schema_proposals"`) => psql` + ${prefix}."id" + , to_json(${prefix}."created_at") as "createdAt" + , to_json(${prefix}."updated_at") as "updatedAt" + , ${prefix}."title" + , ${prefix}."description" + , ${prefix}."stage" + , ${prefix}."target_id" as "targetId" + , ${prefix}."author" + , ${prefix}."composition_status" as "compositionStatus" + , to_json(${prefix}."composition_timestamp") as "compositionTimestamp" + , ${prefix}."composition_status_reason" as "compositionStatusReason" `; const schemaProposalReviewFields = psql` diff --git a/packages/services/api/src/modules/proposals/resolvers/SchemaChange.ts b/packages/services/api/src/modules/proposals/resolvers/SchemaChange.ts index 4ec3eef8df0..132a970a0cb 100644 --- a/packages/services/api/src/modules/proposals/resolvers/SchemaChange.ts +++ b/packages/services/api/src/modules/proposals/resolvers/SchemaChange.ts @@ -1,3 +1,4 @@ +import { SchemaProposalManager } from '../../proposals/providers/schema-proposal-manager'; import type { SchemaChangeResolvers } from './../../../__generated__/types'; export function toTitleCase(str: string) { @@ -6,12 +7,50 @@ export function toTitleCase(str: string) { }); } -export const SchemaChange: Pick = { +export const SchemaChange: Pick = { meta: ({ meta, type }, _arg, _ctx) => { - // @todo consider validating + // no need to validate because this is done when fetched from the database + // and the schema should match the db structure, making a check here redundant return { __typename: toTitleCase(type), ...(meta as any), }; }, + schemaProposalChangeDetails: async (change, _, { injector }) => { + const { selector } = change; + // used for schema changes that proposals don't support. I.e. on contracts + if (!selector) { + return null; + } + + const proposalManager = injector.get(SchemaProposalManager); + /** + * If the change belongs to a schema proposal, then fetch the changes just + * for that proposal + */ + if (selector.schemaProposalId) { + return proposalManager.getProposalChangeDetails({ + targetId: selector.targetId, + schemaProposalId: selector.schemaProposalId, + change, + }); + } + + /** If this change is for a pushed schema (in history) */ + if (selector.schemaVersionId) { + return proposalManager.getImplementedVersionsBySchemaVersionId({ + targetId: selector.targetId, + schemaVersionId: selector.schemaVersionId, + change, + }); + } + + // this finds a matching approved change record based on + // the change hash (because the ID will be different) + // and returns which schema proposal approved the change + return proposalManager.getMatchingApprovedProposalChangeDetails({ + targetId: selector.targetId, + change, + }); + }, }; diff --git a/packages/services/api/src/modules/proposals/resolvers/SchemaProposalChangeDetails.ts b/packages/services/api/src/modules/proposals/resolvers/SchemaProposalChangeDetails.ts new file mode 100644 index 00000000000..640ac081fe3 --- /dev/null +++ b/packages/services/api/src/modules/proposals/resolvers/SchemaProposalChangeDetails.ts @@ -0,0 +1,54 @@ +import { HiveError } from '../../../shared/errors'; +import { SchemaManager } from '../../schema/providers/schema-manager'; +import { IdTranslator } from '../../shared/providers/id-translator'; +import { SchemaProposalManager } from '../providers/schema-proposal-manager'; +import type { SchemaProposalChangeDetailsResolvers } from './../../../__generated__/types'; + +export const SchemaProposalChangeDetails: SchemaProposalChangeDetailsResolvers = { + /* Implement SchemaProposalChangeDetails resolver logic here */ + implementedBy: async (parent, _, { injector }) => { + const versionId = parent?.implementedBy?.id; + if (!versionId) { + return null; + } + const schemaProposalId = parent.schemaProposal.id; + const proposal = await injector + .get(SchemaProposalManager) + .getProposal({ id: schemaProposalId }); + + if (!proposal) { + throw new Error('Proposal not found'); + } + const ref = await injector + .get(IdTranslator) + .resolveTargetReference({ reference: { byId: proposal.targetId } }); + if (!ref) { + throw new HiveError('Target not found'); + } + + const version = await injector.get(SchemaManager).getSchemaVersion({ + versionId, + organizationId: ref.organizationId, + projectId: ref.projectId, + targetId: ref.targetId, + }); + + return version; + }, + schemaProposal: async (parent, _arg, { injector }) => { + if (!parent) { + // this should never happen + throw new Error('Uh oh'); + } + const schemaProposalId = parent.schemaProposal.id; + + const proposal = await injector + .get(SchemaProposalManager) + .getProposal({ id: schemaProposalId }); + + if (!proposal) { + throw new Error('Proposal not found'); + } + return proposal; + }, +}; diff --git a/packages/services/api/src/modules/schema/index.ts b/packages/services/api/src/modules/schema/index.ts index 60f4f1c491c..e6c75d796d2 100644 --- a/packages/services/api/src/modules/schema/index.ts +++ b/packages/services/api/src/modules/schema/index.ts @@ -1,4 +1,5 @@ import { createModule } from 'graphql-modules'; +import { SchemaProposalManager } from '../proposals/providers/schema-proposal-manager'; import { SchemaProposalStorage } from '../proposals/providers/schema-proposal-storage'; import { BreakingSchemaChangeUsageHelper } from './providers/breaking-schema-changes-helper'; import { Contracts } from './providers/contracts'; @@ -34,5 +35,6 @@ export const schemaModule = createModule({ CompositionOrchestrator, ...models, SchemaProposalStorage, + SchemaProposalManager, ], }); diff --git a/packages/services/api/src/modules/schema/module.graphql.mappers.ts b/packages/services/api/src/modules/schema/module.graphql.mappers.ts index 601312589e0..9b37ea2d414 100644 --- a/packages/services/api/src/modules/schema/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/schema/module.graphql.mappers.ts @@ -18,7 +18,20 @@ import type { import type { SchemaCheckWarning } from './providers/models/shared'; export type SchemaChangeConnectionMapper = ReadonlyArray; -export type SchemaChangeMapper = SchemaChangeType; +export type SchemaChangeMapper = SchemaChangeType & { + /** + * Pass a selector to allow + */ + selector?: { + organizationId: string; + projectId: string; + targetId: string; + /** If viewing from a schema proposal */ + schemaProposalId: string | null; + /** If viewing from a schema version */ + schemaVersionId: string | null; + }; +}; export type SchemaChangeApprovalMapper = SchemaCheckApprovalMetadata; export type SchemaErrorConnectionMapper = readonly SchemaError[]; export type SchemaWarningConnectionMapper = readonly SchemaCheckWarning[]; diff --git a/packages/services/api/src/modules/schema/providers/schema-manager.ts b/packages/services/api/src/modules/schema/providers/schema-manager.ts index 06c1cb04ba8..0e5e279bbfc 100644 --- a/packages/services/api/src/modules/schema/providers/schema-manager.ts +++ b/packages/services/api/src/modules/schema/providers/schema-manager.ts @@ -4,6 +4,7 @@ import { parse, print } from 'graphql'; import { Inject, Injectable, Scope } from 'graphql-modules'; import lodash from 'lodash'; import { z } from 'zod'; +import { CommonQueryMethods } from '@hive/postgres'; import { trace, traceFn } from '@hive/service-common'; import type { ConditionalBreakingChangeMetadata, @@ -439,7 +440,7 @@ export class SchemaManager { base_schema: string | null; metadata: string | null; projectType: ProjectType; - actionFn(versionId: string): Promise; + actionFn(versionId: string, trx: CommonQueryMethods): Promise; changes: Array; coordinatesDiff: SchemaCoordinatesDiffResult | null; previousSchemaVersion: string | null; diff --git a/packages/services/api/src/modules/schema/providers/schema-publisher.ts b/packages/services/api/src/modules/schema/providers/schema-publisher.ts index eb0bbd874ac..781673eaa7a 100644 --- a/packages/services/api/src/modules/schema/providers/schema-publisher.ts +++ b/packages/services/api/src/modules/schema/providers/schema-publisher.ts @@ -6,6 +6,7 @@ import lodash from 'lodash'; import promClient from 'prom-client'; import { z } from 'zod'; import { CriticalityLevel } from '@graphql-inspector/core'; +import { CommonQueryMethods } from '@hive/postgres'; import { trace, traceFn } from '@hive/service-common'; import type { ConditionalBreakingChangeMetadata, @@ -35,6 +36,7 @@ import { Mutex, MutexResourceLockedError } from '../../shared/providers/mutex'; import { Storage, type TargetSelector } from '../../shared/providers/storage'; import { TargetManager } from '../../target/providers/target-manager'; import { toGraphQLSchemaCheck } from '../to-graphql-schema-check'; +import { withSelector } from '../utils'; import { ArtifactStorageWriter } from './artifact-storage-writer'; import type { SchemaModuleConfig } from './config'; import { SCHEMA_MODULE_CONFIG } from './config'; @@ -104,6 +106,14 @@ export type PublishInput = Types.SchemaPublishInput & { isSchemaPublishMissingUrlErrorSelected: boolean; }; +type SchemaCheckParentTypes = Types.ResolversParentTypes[ + | 'SchemaCheckSuccess' + | 'GitHubSchemaCheckSuccess' + | 'SchemaCheckError' + | 'GitHubSchemaCheckError']; + +type CheckResult = SchemaCheckParentTypes & Required>; + type BreakPromise = T extends Promise ? U : never; type PublishResult = @@ -113,7 +123,7 @@ type PublishResult = message: 'Missing service name'; } | { - readonly __typename: 'SchemaPublishRetry'; + __typename: 'SchemaPublishRetry'; readonly reason: string; }; @@ -309,7 +319,7 @@ export class SchemaPublisher { 'hive.check.result': result.__typename, }), }) - private async internalCheck(input: CheckInput) { + private async internalCheck(input: CheckInput): Promise { this.logger.info('Checking schema (input=%o)', lodash.omit(input, ['sdl'])); const selector = await this.idTranslator.resolveTargetReference({ @@ -360,7 +370,7 @@ export class SchemaPublisher { if (input.schemaProposalId && schemaProposal?.targetId !== selector.targetId) { return { - __typename: 'SchemaCheckError', + __typename: 'SchemaCheckError' as const, valid: false, changes: [], warnings: [], @@ -370,7 +380,7 @@ export class SchemaPublisher { 'Invalid schema proposal reference. No proposal found with that ID for the target.', }, ], - } as const; + }; } const [latestVersion, latestComposableVersion] = await Promise.all([ @@ -393,7 +403,7 @@ export class SchemaPublisher { // this is a new service. Validate the service name. if (!serviceExists && !isValidServiceName(input.service)) { return { - __typename: 'SchemaCheckError', + __typename: 'SchemaCheckError' as const, valid: false, changes: [], warnings: [], @@ -403,7 +413,7 @@ export class SchemaPublisher { 'Invalid service name. Service name must be 64 characters or less, must start with a letter, and can only contain alphanumeric characters, dash (-), or underscore (_).', }, ], - } as const; + }; } } @@ -424,7 +434,7 @@ export class SchemaPublisher { increaseSchemaCheckCountMetric('rejected'); return { - __typename: 'SchemaCheckError', + __typename: 'SchemaCheckError' as const, valid: false, changes: [], warnings: [], @@ -433,7 +443,7 @@ export class SchemaPublisher { message: 'url is only supported by distributed projects', }, ], - } as const; + }; } if ( @@ -444,7 +454,7 @@ export class SchemaPublisher { increaseSchemaCheckCountMetric('rejected'); return { - __typename: 'SchemaCheckError', + __typename: 'SchemaCheckError' as const, valid: false, changes: [], warnings: [], @@ -453,7 +463,7 @@ export class SchemaPublisher { message: 'Missing service name', }, ], - } as const; + }; } let githubCheckRun: GitHubCheckRun | null = null; @@ -530,7 +540,7 @@ export class SchemaPublisher { const result = SchemaCheckContextIdModel.safeParse(input.contextId); if (!result.success) { return { - __typename: 'SchemaCheckError', + __typename: 'SchemaCheckError' as const, valid: false, changes: [], warnings: [], @@ -539,7 +549,7 @@ export class SchemaPublisher { message: result.error.errors[0].message, }, ], - } as const; + }; } contextId = result.data; } else if (input.github?.repository && input.github.pullRequestNumber) { @@ -1025,43 +1035,61 @@ export class SchemaPublisher { projectId: target.projectId, }; + const schemaChangeSelector = { + organizationId: target.orgId, + projectId: target.projectId, + targetId: target.id, + schemaProposalId: schemaCheck.schemaProposalId, + schemaVersionId: null, + }; + if (checkResult.conclusion === SchemaCheckConclusion.Success) { increaseSchemaCheckCountMetric('accepted'); return { - __typename: 'SchemaCheckSuccess', + __typename: 'SchemaCheckSuccess' as const, valid: true, - schemaProposalChanges: schemaCheck.schemaProposalChanges, - changes: [ - ...(checkResult.state?.schemaChanges?.all ?? []), - ...(checkResult.state?.contracts?.flatMap(contract => [ - ...(contract.schemaChanges?.all?.map(change => ({ - ...change, - message: `[${contract.contractName}] ${change.message}`, - })) ?? []), - ]) ?? []), - ], + schemaProposalChanges: schemaCheck.schemaProposalChanges + ? withSelector(schemaCheck.schemaProposalChanges, schemaChangeSelector) + : null, + changes: withSelector( + [ + ...(checkResult.state?.schemaChanges?.all ?? []), + ...(checkResult.state?.contracts?.flatMap(contract => [ + ...(contract.schemaChanges?.all?.map(change => ({ + ...change, + message: `[${contract.contractName}] ${change.message}`, + })) ?? []), + ]) ?? []), + ], + schemaChangeSelector, + ), warnings: checkResult.state?.schemaPolicyWarnings ?? [], initial: latestVersion == null, schemaCheck: toGraphQLSchemaCheck(schemaCheckSelector, schemaCheck), - } as const; + }; } if (checkResult.conclusion === SchemaCheckConclusion.Failure) { increaseSchemaCheckCountMetric('rejected'); return { - __typename: 'SchemaCheckError', + __typename: 'SchemaCheckError' as const, valid: false, - schemaProposalChanges: schemaCheck.schemaProposalChanges, - changes: [ - ...(checkResult.state.schemaChanges?.all ?? []), - ...(checkResult.state.contracts?.flatMap(contract => [ - ...(contract.schemaChanges?.all?.map(change => ({ - ...change, - message: `[${contract.contractName}] ${change.message}`, - })) ?? []), - ]) ?? []), - ], + schemaProposalChanges: schemaCheck.schemaProposalChanges + ? withSelector(schemaCheck.schemaProposalChanges, schemaChangeSelector) + : null, + changes: await withSelector( + [ + ...(checkResult.state.schemaChanges?.all ?? []), + ...(checkResult.state.contracts?.flatMap(contract => [ + ...(contract.schemaChanges?.all?.map(change => ({ + ...change, + message: `[${contract.contractName}] ${change.message}`, + })) ?? []), + ]) ?? []), + ], + schemaChangeSelector, + ), warnings: checkResult.state.schemaPolicy?.warnings ?? [], errors: [ ...(checkResult.state.schemaChanges?.breaking?.filter( @@ -1086,9 +1114,9 @@ export class SchemaPublisher { message: `[${contract.contractName}] ${change.message}`, })) ?? []), ]) ?? []), - ], + ].map(error => ({ ...error, path: 'path' in error ? error.path?.split('.') : null })), schemaCheck: toGraphQLSchemaCheck(schemaCheckSelector, schemaCheck), - } as const; + }; } // SchemaCheckConclusion.Skip @@ -1100,14 +1128,16 @@ export class SchemaPublisher { if (latestVersion.version.isComposable) { increaseSchemaCheckCountMetric('accepted'); return { - __typename: 'SchemaCheckSuccess', + __typename: 'SchemaCheckSuccess' as const, valid: true, - schemaProposalChanges: schemaCheck.schemaProposalChanges, + schemaProposalChanges: schemaCheck.schemaProposalChanges + ? withSelector(schemaCheck.schemaProposalChanges, schemaChangeSelector) + : null, changes: [], warnings: [], initial: false, schemaCheck: toGraphQLSchemaCheck(schemaCheckSelector, schemaCheck), - } as const; + }; } const contractVersions = await this.contracts.getContractVersionsForSchemaVersion({ @@ -1116,9 +1146,11 @@ export class SchemaPublisher { increaseSchemaCheckCountMetric('rejected'); return { - __typename: 'SchemaCheckError', + __typename: 'SchemaCheckError' as const, valid: false, - schemaProposalChanges: schemaCheck.schemaProposalChanges, + schemaProposalChanges: schemaCheck.schemaProposalChanges + ? withSelector(schemaCheck.schemaProposalChanges, schemaChangeSelector) + : null, changes: [], warnings: [], errors: [ @@ -1134,7 +1166,7 @@ export class SchemaPublisher { ]) ?? []), ], schemaCheck: toGraphQLSchemaCheck(schemaCheckSelector, schemaCheck), - } as const; + }; } async check(input: CheckInput) { @@ -1219,7 +1251,7 @@ export class SchemaPublisher { return { __typename: 'SchemaPublishMissingServiceError' as const, message: 'Missing service name', - } as const; + } satisfies PublishResult; } let serviceExists = false; @@ -1232,7 +1264,7 @@ export class SchemaPublisher { // this is a new service. Validate the service name. if (!serviceExists && !isValidServiceName(input.service)) { return { - __typename: 'SchemaPublishError', + __typename: 'SchemaPublishError' as const, valid: false, changes: [], errors: [ @@ -1241,7 +1273,7 @@ export class SchemaPublisher { 'Invalid service name. Service name must be 64 characters or less, must start with a letter, and can only contain alphanumeric characters, dash (-), or underscore (_).', }, ], - }; + } satisfies PublishResult; } } @@ -2029,7 +2061,15 @@ export class SchemaPublisher { metadata: input.metadata ?? null, projectType: project.type, github, - actionFn: async (versionId: string) => { + actionFn: async (versionId: string, trx: CommonQueryMethods) => { + await this.schemaProposals.runBackgroundImplementationTracker( + { + targetId: target.id, + schemaVersionId: versionId, + }, + trx, + ); + if (composable && fullSchemaSdl) { const contracts: Array<{ name: string; sdl: string; supergraph: string }> = []; for (const contract of publishState.contracts ?? []) { @@ -2239,7 +2279,7 @@ export class SchemaPublisher { }> | null; schemaCheckId: string | null; failedContractCompositionCount: number; - }) { + }): Promise> { try { let title: string; let summary: string; @@ -2287,7 +2327,7 @@ export class SchemaPublisher { .join('\n\n'); } - const checkRun = await this.gitHubIntegrationManager.updateCheckRun({ + await this.gitHubIntegrationManager.updateCheckRun({ organizationId: args.project.orgId, conclusion: conclusion === SchemaCheckConclusion.Success ? 'success' : 'failure', githubCheckRun: args.githubCheckRun, @@ -2310,7 +2350,7 @@ export class SchemaPublisher { return { __typename: 'GitHubSchemaCheckSuccess' as const, message: 'Check-run created', - checkRun, + // checkRun, }; } catch (error: any) { Sentry.captureException(error); diff --git a/packages/services/api/src/modules/schema/resolvers/ContractCheck.ts b/packages/services/api/src/modules/schema/resolvers/ContractCheck.ts index 2fb067d0012..f31e4ed1d14 100644 --- a/packages/services/api/src/modules/schema/resolvers/ContractCheck.ts +++ b/packages/services/api/src/modules/schema/resolvers/ContractCheck.ts @@ -26,8 +26,14 @@ export const ContractCheck: ContractCheckResolvers = { } return [ - ...(contractCheck.breakingSchemaChanges ?? []), - ...(contractCheck.safeSchemaChanges ?? []), + ...(contractCheck.breakingSchemaChanges?.map(v => ({ + ...v, + schemaProposalChangeDetails: null, // contracts are not supported by proposals yet + })) ?? []), + ...(contractCheck.safeSchemaChanges?.map(v => ({ + ...v, + schemaProposalChangeDetails: null, + })) ?? []), ]; }, }; diff --git a/packages/services/api/src/modules/schema/resolvers/FailedSchemaCheck.ts b/packages/services/api/src/modules/schema/resolvers/FailedSchemaCheck.ts index 6a94546acf3..72c752e7b43 100644 --- a/packages/services/api/src/modules/schema/resolvers/FailedSchemaCheck.ts +++ b/packages/services/api/src/modules/schema/resolvers/FailedSchemaCheck.ts @@ -8,10 +8,36 @@ export const FailedSchemaCheck: FailedSchemaCheckResolvers = { return injector.get(SchemaCheckManager).getSchemaVersion(schemaCheck); }, safeSchemaChanges: (schemaCheck, _, { injector }) => { - return injector.get(SchemaCheckManager).getSafeSchemaChanges(schemaCheck); + const selector = { + organizationId: schemaCheck.selector.organizationId, + projectId: schemaCheck.selector.projectId, + targetId: schemaCheck.targetId, + schemaProposalId: schemaCheck.schemaProposalId, + schemaVersionId: null, + }; + return injector + .get(SchemaCheckManager) + .getSafeSchemaChanges(schemaCheck) + ?.map(c => ({ + ...c, + selector, + })); }, breakingSchemaChanges: (schemaCheck, _, { injector }) => { - return injector.get(SchemaCheckManager).getBreakingSchemaChanges(schemaCheck); + const selector = { + organizationId: schemaCheck.selector.organizationId, + projectId: schemaCheck.selector.projectId, + targetId: schemaCheck.targetId, + schemaProposalId: schemaCheck.schemaProposalId, + schemaVersionId: null, + }; + return injector + .get(SchemaCheckManager) + .getBreakingSchemaChanges(schemaCheck) + ?.map(c => ({ + ...c, + selector, + })); }, compositionErrors: schemaCheck => { return schemaCheck.schemaCompositionErrors; @@ -47,6 +73,19 @@ export const FailedSchemaCheck: FailedSchemaCheckResolvers = { return injector.get(SchemaCheckManager).getConditionalBreakingChangeMetadata(schemaCheck); }, schemaChanges: (schemaCheck, _, { injector }) => { - return injector.get(SchemaCheckManager).getAllSchemaChanges(schemaCheck); + const selector = { + organizationId: schemaCheck.selector.organizationId, + projectId: schemaCheck.selector.projectId, + targetId: schemaCheck.targetId, + schemaProposalId: schemaCheck.schemaProposalId, + schemaVersionId: null, + }; + return injector + .get(SchemaCheckManager) + .getAllSchemaChanges(schemaCheck) + ?.map(c => ({ + ...c, + selector, + })); }, }; diff --git a/packages/services/api/src/modules/schema/resolvers/Mutation/schemaCheck.ts b/packages/services/api/src/modules/schema/resolvers/Mutation/schemaCheck.ts index ba779ef8489..877c750a2e1 100644 --- a/packages/services/api/src/modules/schema/resolvers/Mutation/schemaCheck.ts +++ b/packages/services/api/src/modules/schema/resolvers/Mutation/schemaCheck.ts @@ -1,29 +1,69 @@ +import { HiveError } from '../../../../shared/errors'; +import { SchemaProposalManager } from '../../../proposals/providers/schema-proposal-manager'; +import { IdTranslator } from '../../../shared/providers/id-translator'; import { SchemaPublisher } from '../../providers/schema-publisher'; import type { MutationResolvers } from './../../../../__generated__/types'; export const schemaCheck: NonNullable = async ( _, { input }, - { injector }, + { injector, session }, ) => { + if (typeof input.schemaProposalId === 'string') { + const selector = await injector + .get(IdTranslator) + .resolveTargetReference({ reference: input.target ?? null }); + + if (selector?.targetId) { + await session.assertPerformAction({ + action: 'schemaProposal:modify', + organizationId: selector.organizationId, + params: { + organizationId: selector.organizationId, + projectId: selector.projectId, + targetId: selector.targetId, + }, + }); + const proposal = await injector + .get(SchemaProposalManager) + .getProposal({ id: input.schemaProposalId }); + if (proposal?.targetId !== selector?.targetId) { + throw new HiveError('Proposal not found'); + } + } + } + const result = await injector.get(SchemaPublisher).check({ ...input, service: input.service?.toLowerCase(), target: input.target ?? null, - schemaProposalId: input.schemaProposalId, // @todo check permission + schemaProposalId: input.schemaProposalId, }); - if ('changes' in result && result.changes) { - return { - ...result, - schemaProposalChanges: result.schemaProposalChanges, - changes: result.changes, - errors: - result.errors?.map(error => ({ - ...error, - path: 'path' in error ? error.path?.split('.') : null, - })) ?? [], - }; + if ('changes' in result) { + if (result.changes) { + const proposalId = input.schemaProposalId; + const schemaProposalChanges = + result.schemaProposalChanges?.map(c => { + return { + ...c, + schemaProposalChangeDetails: + typeof proposalId === 'string' + ? { + schemaProposal: { + id: proposalId, + }, + implementedBy: null, // when running a check, there's no way that this has already been implemented + } + : null, + }; + }) ?? null; + + return { + ...result, + schemaProposalChanges, + }; + } } return result; diff --git a/packages/services/api/src/modules/schema/resolvers/Mutation/schemaPublish.ts b/packages/services/api/src/modules/schema/resolvers/Mutation/schemaPublish.ts index 7acc1162e00..bff44f370dc 100644 --- a/packages/services/api/src/modules/schema/resolvers/Mutation/schemaPublish.ts +++ b/packages/services/api/src/modules/schema/resolvers/Mutation/schemaPublish.ts @@ -1,4 +1,5 @@ import { parseResolveInfo } from 'graphql-parse-resolve-info'; +import { SchemaChangeType } from '@hive/storage'; import { SchemaPublisher } from '../../providers/schema-publisher'; import type { MutationResolvers } from './../../../../__generated__/types'; @@ -26,7 +27,7 @@ export const schemaPublish: NonNullable = as if ('changes' in result) { return { ...result, - changes: result.changes, + changes: (result.changes as SchemaChangeType[]) ?? null, }; } diff --git a/packages/services/api/src/modules/schema/resolvers/SchemaVersion.ts b/packages/services/api/src/modules/schema/resolvers/SchemaVersion.ts index 7e5c66421a3..51bee3953a9 100644 --- a/packages/services/api/src/modules/schema/resolvers/SchemaVersion.ts +++ b/packages/services/api/src/modules/schema/resolvers/SchemaVersion.ts @@ -71,13 +71,46 @@ export const SchemaVersion: SchemaVersionResolvers = { return injector.get(SchemaVersionHelper).getSchemaCompositionErrors(version); }, breakingSchemaChanges: async (version, _, { injector }) => { - return injector.get(SchemaVersionHelper).getBreakingSchemaChanges(version); + const selector = { + targetId: version.targetId, + projectId: version.projectId, + organizationId: version.organizationId, + schemaProposalId: null, + schemaVersionId: version.id, + }; + const changes = await injector.get(SchemaVersionHelper).getBreakingSchemaChanges(version); + return changes?.map(c => ({ + ...c, + selector, + })); }, safeSchemaChanges: async (version, _, { injector }) => { - return injector.get(SchemaVersionHelper).getSafeSchemaChanges(version); + const selector = { + targetId: version.targetId, + projectId: version.projectId, + organizationId: version.organizationId, + schemaProposalId: null, + schemaVersionId: version.id, + }; + const changes = await injector.get(SchemaVersionHelper).getSafeSchemaChanges(version); + return changes?.map(c => ({ + ...c, + selector, + })); }, schemaChanges: async (version, _, { injector }) => { - return injector.get(SchemaVersionHelper).getAllSchemaChanges(version); + const selector = { + targetId: version.targetId, + projectId: version.projectId, + organizationId: version.organizationId, + schemaProposalId: null, + schemaVersionId: version.id, + }; + const changes = await injector.get(SchemaVersionHelper).getAllSchemaChanges(version); + return changes?.map(c => ({ + ...c, + selector, + })); }, supergraph: async (version, _, { injector }) => { return injector.get(SchemaVersionHelper).getSupergraphSdl(version); diff --git a/packages/services/api/src/modules/schema/resolvers/SuccessfulSchemaCheck.ts b/packages/services/api/src/modules/schema/resolvers/SuccessfulSchemaCheck.ts index 1e4cae9781b..47af557bd88 100644 --- a/packages/services/api/src/modules/schema/resolvers/SuccessfulSchemaCheck.ts +++ b/packages/services/api/src/modules/schema/resolvers/SuccessfulSchemaCheck.ts @@ -2,6 +2,7 @@ import { parseCliAuthor } from '../lib/parse-cli-author'; import { ContractsManager } from '../providers/contracts-manager'; import { SchemaCheckManager } from '../providers/schema-check-manager'; import { SchemaManager } from '../providers/schema-manager'; +import { withSelector } from '../utils'; import type { SuccessfulSchemaCheckResolvers } from './../../../__generated__/types'; export const SuccessfulSchemaCheck: SuccessfulSchemaCheckResolvers = { @@ -9,10 +10,26 @@ export const SuccessfulSchemaCheck: SuccessfulSchemaCheckResolvers = { return injector.get(SchemaCheckManager).getSchemaVersion(schemaCheck); }, safeSchemaChanges: (schemaCheck, _, { injector }) => { - return injector.get(SchemaCheckManager).getSafeSchemaChanges(schemaCheck); + const selector = { + organizationId: schemaCheck.selector.organizationId, + projectId: schemaCheck.selector.projectId, + targetId: schemaCheck.targetId, + schemaProposalId: schemaCheck.schemaProposalId, + schemaVersionId: null, + }; + const changes = injector.get(SchemaCheckManager).getSafeSchemaChanges(schemaCheck); + return changes ? withSelector(changes, selector) : null; }, breakingSchemaChanges: (schemaCheck, _, { injector }) => { - return injector.get(SchemaCheckManager).getBreakingSchemaChanges(schemaCheck); + const selector = { + organizationId: schemaCheck.selector.organizationId, + projectId: schemaCheck.selector.projectId, + targetId: schemaCheck.targetId, + schemaProposalId: schemaCheck.schemaProposalId, + schemaVersionId: null, + }; + const changes = injector.get(SchemaCheckManager).getBreakingSchemaChanges(schemaCheck); + return changes ? withSelector(changes, selector) : null; }, hasSchemaCompositionErrors: (schemaCheck, _, { injector }) => { return injector.get(SchemaCheckManager).getHasSchemaCompositionErrors(schemaCheck); @@ -71,6 +88,33 @@ export const SuccessfulSchemaCheck: SuccessfulSchemaCheckResolvers = { return injector.get(SchemaCheckManager).getConditionalBreakingChangeMetadata(schemaCheck); }, schemaChanges: (schemaCheck, _, { injector }) => { - return injector.get(SchemaCheckManager).getAllSchemaChanges(schemaCheck); + const selector = { + organizationId: schemaCheck.selector.organizationId, + projectId: schemaCheck.selector.projectId, + targetId: schemaCheck.targetId, + schemaProposalId: schemaCheck.schemaProposalId, + schemaVersionId: null, + }; + const changes = injector.get(SchemaCheckManager).getAllSchemaChanges(schemaCheck); + return changes ? withSelector(changes, selector) : null; + }, + schemaProposalChanges: async ({ + schemaProposalChanges, + schemaProposalId, + targetId, + selector, + }) => { + const select = { + organizationId: selector.organizationId, + projectId: selector.projectId, + targetId: targetId, + schemaProposalId, + schemaVersionId: null, + }; + if (!schemaProposalChanges) { + return null; + } + + return schemaProposalChanges ? withSelector(schemaProposalChanges, select) : null; }, }; diff --git a/packages/services/api/src/modules/schema/utils.ts b/packages/services/api/src/modules/schema/utils.ts index f10d96c4451..1e506ebff92 100644 --- a/packages/services/api/src/modules/schema/utils.ts +++ b/packages/services/api/src/modules/schema/utils.ts @@ -432,3 +432,17 @@ export function memo(fn: (arg: A) => R, cacheKeyFn: (arg: A) => K): (ar return memoizedResult; }; } + +export function withSelector( + t: T[], + selector: { + organizationId: string; + projectId: string; + targetId: string; + } & S, +) { + return t.map(o => ({ + ...o, + selector, + })); +} diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index 9382a81e748..7c685a71682 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -1,6 +1,6 @@ import { Injectable } from 'graphql-modules'; import type { PolicyConfigurationObject } from '@hive/policy'; -import { PostgresDatabasePool } from '@hive/postgres'; +import { CommonQueryMethods, PostgresDatabasePool } from '@hive/postgres'; import type { ConditionalBreakingChangeMetadata, PaginatedOrganizationInvitationConnection, @@ -443,7 +443,7 @@ export interface Storage { _: { serviceName: string; composable: boolean; - actionFn(versionId: string): Promise; + actionFn(versionId: string, trx: CommonQueryMethods): Promise; changes: Array | null; diffSchemaVersionId: string | null; conditionalBreakingChangeMetadata: null | ConditionalBreakingChangeMetadata; @@ -484,7 +484,7 @@ export interface Storage { commit: string; logIds: string[]; base_schema: string | null; - actionFn(versionId: string): Promise; + actionFn(versionId: string, trx: CommonQueryMethods): Promise; changes: Array; previousSchemaVersion: null | string; diffSchemaVersionId: null | string; diff --git a/packages/services/server/src/use-armor.ts b/packages/services/server/src/use-armor.ts index 7bb2055ae91..9dc1a1d8166 100644 --- a/packages/services/server/src/use-armor.ts +++ b/packages/services/server/src/use-armor.ts @@ -114,7 +114,7 @@ export function useArmor< function parseWithTokenLimit(source: string | Source, options: ParseOptions) { const parser = new MaxTokensParserWLexer(source, { ...options, - n: 800, + n: 1_000, onReject: [ (_, error) => { rejectedRequests.inc({ diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 224b9403d7f..710c42771d1 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -281,6 +281,16 @@ export interface projects { validation_url: string | null; } +export interface proposal_approved_changes { + change: any; + hash: string; + id: string; + proposal_id: string; + schema_version_id: string | null; + service: string | null; + target_id: string; +} + export interface saved_filters { created_at: Date; created_by_user_id: string; @@ -549,6 +559,7 @@ export interface DBTables { organizations: organizations; organizations_billing: organizations_billing; projects: projects; + proposal_approved_changes: proposal_approved_changes; saved_filters: saved_filters; schema_change_approvals: schema_change_approvals; schema_checks: schema_checks; diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 5659d54180c..69f906d6ddd 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -2299,7 +2299,7 @@ export async function createStorage( }); } - await args.actionFn(newVersion.id); + await args.actionFn(newVersion.id, trx); return { kind: 'composite', @@ -2412,7 +2412,7 @@ export async function createStorage( }); } - await input.actionFn(version.id); + await input.actionFn(version.id, trx); return { version, diff --git a/packages/services/workflows/package.json b/packages/services/workflows/package.json index ee208dad925..8d96f6544a9 100644 --- a/packages/services/workflows/package.json +++ b/packages/services/workflows/package.json @@ -10,6 +10,7 @@ }, "devDependencies": { "@graphql-hive/logger": "1.0.9", + "@graphql-inspector/compare-changes": "0.1.0-alpha-20260410200850-4134db734dce207751762bfcbe74ab87eab30fe9", "@graphql-inspector/core": "7.1.2", "@graphql-inspector/patch": "0.1.3", "@graphql-yoga/redis-event-target": "3.0.3", diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index d14be17d574..d98a46542aa 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -43,6 +43,8 @@ const modules = await Promise.all([ import('./tasks/usage-rate-limit-exceeded.js'), import('./tasks/usage-rate-limit-warning.js'), import('./tasks/schema-proposal-composition.js'), + import('./tasks/schema-proposal-approval.js'), + import('./tasks/schema-proposal-implementation.js'), ]); const crontab = ` diff --git a/packages/services/workflows/src/kit.ts b/packages/services/workflows/src/kit.ts index 213933ed233..a8e49c46aff 100644 --- a/packages/services/workflows/src/kit.ts +++ b/packages/services/workflows/src/kit.ts @@ -3,7 +3,7 @@ import { memoryDriver } from 'bentocache/build/src/drivers/memory'; import { makeWorkerUtils, WorkerUtils, type JobHelpers, type Task } from 'graphile-worker'; import { z } from 'zod'; import { Logger } from '@graphql-hive/logger'; -import { PostgresDatabasePool, psql } from '@hive/postgres'; +import { CommonQueryMethods, PostgresDatabasePool, psql } from '@hive/postgres'; import { bridgeGraphileLogger } from '@hive/pubsub'; import type { Context } from './context'; @@ -108,6 +108,7 @@ export class TaskScheduler { /** how long should the task be de-duped in milliseconds */ ttl: number; }; + trx?: CommonQueryMethods; }, ) { this.logger.info( @@ -127,13 +128,13 @@ export class TaskScheduler { let shouldSkip = true; - const { pgPool } = this; + const conn = opts?.trx ?? this.pgPool; await this.cache.getOrSet({ key: `${taskDefinition.name}:${dedupeKey}`, ttl: opts.dedupe.ttl, async factory() { - const result = await pgPool.anyFirst( + const result = await conn.anyFirst( psql` INSERT INTO "graphile_worker_deduplication" ("task_name", "dedupe_key", "expires_at") VALUES(${taskDefinition.name}, ${dedupeKey}, ${expiresAt}) @@ -161,10 +162,21 @@ export class TaskScheduler { } } - const job = await tools.addJob(taskDefinition.name, { - requestId: opts?.requestId, - input, - }); + let job: { id: string }; + if (opts?.trx) { + job = (await opts.trx.maybeOne( + psql` + SELECT id FROM graphile_worker.add_job( + ${taskDefinition.name} + ,${JSON.stringify({ input, requestId: opts.requestId })} + )`, + )) as { id: string }; + } else { + job = await tools.addJob(taskDefinition.name, { + requestId: opts?.requestId, + input, + }); + } this.logger.info( { diff --git a/packages/services/workflows/src/lib/schema/provider.ts b/packages/services/workflows/src/lib/schema/provider.ts index 32dedd7aa9e..976bb41cf70 100644 --- a/packages/services/workflows/src/lib/schema/provider.ts +++ b/packages/services/workflows/src/lib/schema/provider.ts @@ -1,13 +1,18 @@ import { DocumentNode, GraphQLError, parse, print, SourceLocation } from 'graphql'; import { z } from 'zod'; import type { Logger } from '@graphql-hive/logger'; +import { generateChangeHash, isChangeEqual } from '@graphql-inspector/compare-changes'; import type { Change } from '@graphql-inspector/core'; import { errors, patch } from '@graphql-inspector/patch'; import type { Project, SchemaObject } from '@hive/api'; import type { ComposeAndValidateResult } from '@hive/api/shared/entities'; -import { PostgresDatabasePool, psql } from '@hive/postgres'; +import { CommonQueryMethods, PostgresDatabasePool, psql } from '@hive/postgres'; import type { ContractsInputType, SchemaBuilderApi } from '@hive/schema'; -import { decodeCreatedAtAndUUIDIdBasedCursor, HiveSchemaChangeModel } from '@hive/storage'; +import { + decodeCreatedAtAndUUIDIdBasedCursor, + HiveSchemaChangeModel, + SchemaChangeModel, +} from '@hive/storage'; import { createTRPCProxyClient, httpLink } from '@trpc/client'; type SchemaProviderConfig = { @@ -28,10 +33,15 @@ const SchemaProposalChangesModel = z.object({ id: z.string().uuid(), serviceName: z.string().nullable(), serviceUrl: z.string().nullable(), - schemaProposalChanges: z.array(HiveSchemaChangeModel).default([]), + schemaProposalChanges: z + .array(HiveSchemaChangeModel) + .nullable() + .transform(a => a ?? []), createdAt: z.string(), }); +type SchemaProposalChangesModelType = z.TypeOf; + function createExternalConfig(config: Project['externalComposition']) { // : ExternalCompositionConfig { if (config && config.enabled) { @@ -270,12 +280,25 @@ export function schemaProvider(providerConfig: SchemaProviderConfig) { return baseSchema; }, - async proposedSchemas(args: { - targetId: string; - proposalId: string; - cursor?: string | null; - pool: PostgresDatabasePool; - }) { + /** + * Loops through all schema checks that are part of the schema proposal. This uses pagination + * to avoid loading too much at once + * + * This is hard capped at 2_000 subgraphs for safety. + **/ + async forEachProposalCheck( + args: { + targetId: string; + proposalId: string; + cursor?: string | null; + pool: PostgresDatabasePool; + }, + callback: ( + change: Omit & { + schemaProposalChanges: Change[]; + }, + ) => void | Promise, + ) { const now = new Date().toISOString(); let cursor: { createdAt: string; @@ -288,11 +311,6 @@ export function schemaProvider(providerConfig: SchemaProviderConfig) { // fetch all latest schemas. Support up to 2_000 subgraphs. const maxLoops = 100; - const services = await this.latestComposableSchemas({ - targetId: args.targetId, - pool: args.pool, - }); - let nextCursor = cursor; // collect changes in paginated requests to avoid stalling the db let i = 0; @@ -350,48 +368,229 @@ export function schemaProvider(providerConfig: SchemaProviderConfig) { LIMIT 20 `); - const changes = result.map(row => { - const value = SchemaProposalChangesModel.parse(row); + const checks = result.map(row => { + const check = SchemaProposalChangesModel.parse(row); return { - ...value, - schemaProposalChanges: value.schemaProposalChanges.map(c => { - const change: Change = { - ...c, + ...check, + schemaProposalChanges: check.schemaProposalChanges?.map((c): Change => { + return { + message: c.message, + meta: c.meta, + type: c.type, path: c.path ?? undefined, criticality: { level: c.criticality, }, - }; - return change; + } satisfies Change; }), }; }); - if (changes.length === 20) { + for (const check of checks) { + if (check.schemaProposalChanges.length !== 0) { + await callback(check); + } + } + + if (checks.length === 20) { nextCursor = { // Keep the created at because we want the same set of checks when joining on the "latest". - createdAt: nextCursor?.createdAt ?? changes[0]?.createdAt ?? now, - id: changes[changes.length - 1].id, + createdAt: nextCursor?.createdAt ?? checks[0]?.createdAt ?? now, + id: checks[checks.length - 1].id, }; } else { nextCursor = null; } + } while (nextCursor && ++i < maxLoops); + }, - for (const change of changes) { - const service = services.find(s => change.serviceName === s.serviceName); - if (service) { - const ast = parse(service.sdl, { noLocation: true }); - service.sdl = print( - patch(ast, change.schemaProposalChanges, { onError: errors.looseErrorHandler }), - ); - if (change.serviceUrl) { - service.serviceUrl = change.serviceUrl; - } + async proposedSchemas(args: { + targetId: string; + proposalId: string; + cursor?: string | null; + pool: PostgresDatabasePool; + }) { + const services = await this.latestComposableSchemas({ + targetId: args.targetId, + pool: args.pool, + }); + + await this.forEachProposalCheck(args, change => { + const service = services.find(s => change.serviceName === s.serviceName); + if (service) { + const ast = parse(service.sdl, { noLocation: true }); + service.sdl = print( + patch(ast, change.schemaProposalChanges, { onError: errors.looseErrorHandler }), + ); + if (change.serviceUrl) { + service.serviceUrl = change.serviceUrl; } } - } while (nextCursor && ++i < maxLoops); + }); return services; }, + + async approveChanges( + conn: CommonQueryMethods, + records: { + change: Change; + proposalId: string; + service?: string; + targetId: string; + }[], + ) { + if (records.length === 0) { + return; + } + const values = records.map( + r => psql`( + ${generateChangeHash(r.change)} + ,${JSON.stringify(r.change)} + ,${r.proposalId} + ,${r.service ?? null} + ,${r.targetId} + )`, + ); + + await conn.query(psql` + INSERT INTO "proposal_approved_changes" ( + hash + ,change + ,proposal_id + ,service + ,target_id + ) + VALUES ${psql.join(values, psql.fragment`,`)} + `); + }, + + async implementChanges( + conn: CommonQueryMethods, + records: { + /** Change ID */ + id: string; + + schemaVersionId: string; + }[], + ) { + await conn.query(psql` + UPDATE proposal_approved_changes as p + SET schema_version_id = v."schemaVersionId" + FROM jsonb_to_recordset( + ${psql.jsonb(records)} + ) as v(id uuid, "schemaVersionId" uuid) + WHERE p.id = v.id; + `); + }, + + /** + * If we have a set of changes, this looks up to determine whether or not those changes + * have been approved. This is useful for looking up (on check or publish) whether the + * change matches any of the approved proposals' changess, because in this situation + * there is no known proposal or schema version to match on. This function queries for all + * passed in changes, so any batching/pagination should be done prior to calling this function. + * + * This is also ran for the schema checks page to display whether or not a change has + * been approved by a proposal or not. + * + * @returns An array of change approval records. The index corresponds with the original + * changes array passed. + */ + async getEquivalentUnimplementedApprovedChanges( + conn: CommonQueryMethods, + args: { changes: Change[]; targetId: string }, + ) { + if (args.changes.length === 0) { + return []; + } + + // calculate hashes the same order as the changes + const hashes = args.changes.map(generateChangeHash); + + // fetch the changes based on the hash. Note that ordering is not guaranteed here + const result = await conn.any(psql` + SELECT ${proposalApprovedChangeFields(psql`"c"`)} + FROM "proposal_approved_changes" as "c" + WHERE hash = ANY(${psql.array(hashes, 'text')}) + AND target_id = ${args.targetId} + AND schema_version_id IS NULL + `); + + const approvedChanges = result.map(row => ProposalApprovedChangeModel.parse(row)); + const approvedChangeLookup = Map.groupBy(approvedChanges, c => c.hash); + + // iterate over all changes and hashes, determine if this change is within the approvedChanges list + return args.changes.map((change, i) => { + const hash = hashes[i]; + const approvedMatch = approvedChangeLookup.get(hash); + const changeApproval = + approvedMatch?.find(match => { + return isChangeEqual(match.change as Change, change); + }) ?? null; + + return changeApproval; + }); + }, + + async schemaVersionChanges({ + pool, + versionId, + }: { + pool: PostgresDatabasePool; + versionId: string; + }) { + const changes = await pool + .any( + psql`/* getSchemaChangesForVersion */ + SELECT + "change_type" as "type", + "meta", + "severity_level" as "severityLevel", + "is_safe_based_on_usage" as "isSafeBasedOnUsage" + FROM + "schema_version_changes" + WHERE + "schema_version_id" = ${versionId} + `, + ) + .then(z.array(HiveSchemaChangeModel).parse); + + if (changes.length === 0) { + return null; + } + + return changes; + }, }; } + +const proposalApprovedChangeFields = (prefix = psql`"proposal_approved_changes"`) => psql` + ${prefix}."id" + ,${prefix}."hash" + ,${prefix}."change" + ,${prefix}."proposal_id" as "proposalId" + ,${prefix}."schema_version_id" as "schemaVersionId" + ,${prefix}."service" + ,${prefix}."target_id" as "targetId" +`; + +const ProposalApprovedChangeModel = z + .object({ + id: z.string(), + // the type and path of the change is baked into the hash, so it's safe + hash: z.string(), + change: SchemaChangeModel, + proposalId: z.string(), + schemaVersionId: z.string().nullable(), + service: z.string().nullable(), + targetId: z.string(), + }) + .transform(data => ({ + ...data, + change: { + // add back the path since that doesn't get saved to the db except for in the hash + path: data.hash.split(':')[1] as string | undefined, + ...data.change, + }, + })); diff --git a/packages/services/workflows/src/tasks/schema-proposal-approval.ts b/packages/services/workflows/src/tasks/schema-proposal-approval.ts new file mode 100644 index 00000000000..cb0ba464548 --- /dev/null +++ b/packages/services/workflows/src/tasks/schema-proposal-approval.ts @@ -0,0 +1,63 @@ +/** + * When a schema proposal is approved, then all changes for all checks associated + * that proposal must be added to the approved changes list. This is to allow + * future checks/publishes/etc to validate their change against this approved list. + * + * This job listens for a schema proposal approval, loops over all the latest + * checks for that proposal, and inserts those change records into the approved list table. + */ + +import { z } from 'zod'; +import { defineTask, implementTask } from '../kit.js'; + +export const SchemaProposalApprovalTask = defineTask({ + name: 'schemaProposalApproval', + schema: z.object({ + proposalId: z.string(), + targetId: z.string(), + }), +}); + +export const task = implementTask(SchemaProposalApprovalTask, async args => { + const pool = args.context.pg; + try { + // collect all changes + const changes: Parameters[1] = []; + await args.context.schema.forEachProposalCheck( + { + pool, + proposalId: args.input.proposalId, + targetId: args.input.targetId, + }, + async check => { + const checkChanges = check.schemaProposalChanges.map(change => ({ + change, + proposalId: args.input.proposalId, + service: check.serviceName ?? undefined, + targetId: args.input.targetId, + })); + changes.push(...checkChanges); + }, + ); + + if (changes.length === 0) { + args.logger.info( + 'No changes found on proposal to approve. (proposal=%s)', + args.input.proposalId, + ); + return; + } + + args.logger.info( + 'Approving changes (count=%d, proposal=%s)', + changes.length, + args.input.proposalId, + ); + // Approve all changes in a single call. If this becomes a bottleneck, then + // implement snapshots to allow inserting in chunks + await args.context.schema.approveChanges(pool, changes); + } catch (e: unknown) { + args.logger.error('Proposal approval failed from %s', String(e)); + throw e; + } +}); diff --git a/packages/services/workflows/src/tasks/schema-proposal-implementation.ts b/packages/services/workflows/src/tasks/schema-proposal-implementation.ts new file mode 100644 index 00000000000..a1594444857 --- /dev/null +++ b/packages/services/workflows/src/tasks/schema-proposal-implementation.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; +import { defineTask, implementTask } from '../kit.js'; + +/** + * Runs a check to determine if the changes done in a specific schema version + * correspond to any of the target's approved schema proposals. If they match, + * then this inserts a record to track the implementation. + */ +export const SchemaProposalImplementationTask = defineTask({ + name: 'schemaProposalImplementation', + schema: z.object({ + targetId: z.string(), + schemaVersionId: z.string(), + }), +}); + +export const task = implementTask(SchemaProposalImplementationTask, async args => { + const pool = args.context.pg; + + try { + const implementedChanges = await args.context.schema.schemaVersionChanges({ + pool, + versionId: args.input.schemaVersionId, + }); + + if (!implementedChanges || implementedChanges.length === 0) { + args.logger.info( + 'No changes found for schema version. Ignoring. (version=%s, target=%s)', + args.input.schemaVersionId, + args.input.targetId, + ); + return; + } + + const approvedImplementedChanges = + await args.context.schema.getEquivalentUnimplementedApprovedChanges(pool, { + changes: implementedChanges.map(c => ({ + ...c, + path: c.path ?? undefined, + criticality: { + level: c.criticality, + }, + })), + targetId: args.input.targetId, + }); + + const implementations = approvedImplementedChanges + .filter(c => !!c) + .map(c => ({ id: c.id, schemaVersionId: args.input.schemaVersionId })); + + if (implementations.length) { + args.logger.info( + 'Transitioning changes to implemented (version=%s, target=%s, count=%d)', + args.input.schemaVersionId, + args.input.targetId, + implementations.length, + ); + await args.context.schema.implementChanges(pool, implementations); + } else { + args.logger.info( + 'No approved changes found matching schema version (version=%s, target=%s)', + args.input.schemaVersionId, + args.input.targetId, + ); + } + } catch (e: unknown) { + args.logger.error('Change implementation failed from %s', String(e)); + throw e; + } +}); diff --git a/packages/web/app/src/components/target/history/errors-and-changes.tsx b/packages/web/app/src/components/target/history/errors-and-changes.tsx index 133550721a1..7dadbbd6f8a 100644 --- a/packages/web/app/src/components/target/history/errors-and-changes.tsx +++ b/packages/web/app/src/components/target/history/errors-and-changes.tsx @@ -1,7 +1,7 @@ import { ReactElement } from 'react'; import { clsx } from 'clsx'; import { format } from 'date-fns'; -import { BoxIcon, CheckIcon } from 'lucide-react'; +import { BoxIcon, CheckIcon, NotebookIcon } from 'lucide-react'; import reactStringReplace from 'react-string-replace'; import { Label, Label as LegacyLabel } from '@/components/common'; import { @@ -14,6 +14,7 @@ import { import { Button } from '@/components/ui/button'; import { Heading } from '@/components/ui/heading'; import { PulseIcon } from '@/components/ui/icon'; +import { Link as UILink } from '@/components/ui/link'; import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Table, @@ -119,6 +120,12 @@ const ChangesBlock_SchemaChangeWithUsageFragment = graphql(` } totalCount } + schemaProposalChangeDetails { + schemaProposal { + id + title + } + } } `); @@ -132,6 +139,12 @@ export const ChangesBlock_SchemaChangeFragment = graphql(` ...ChangesBlock_SchemaChangeApprovalFragment } isSafeBasedOnUsage + schemaProposalChangeDetails { + schemaProposal { + id + title + } + } } `); @@ -290,6 +303,17 @@ function ChangeItem( + {change.schemaProposalChangeDetails && ( + <> + + + )} {change.approval && ( {change.severityReason ?? - `No details available for this ${ - change.severityLevel === SeverityLevelType.Breaking ? 'breaking ' : '' - }change.`} + (!change.schemaProposalChangeDetails && + !change.approval && + `No details available for this ${ + change.severityLevel === SeverityLevelType.Breaking ? 'breaking ' : '' + }change.`)} )} @@ -690,6 +716,34 @@ function ChangeItem( ); } +function ProposedByBadge(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + schemaProposalId: string; + schemaProposalTitle: string; +}) { + return ( +
+ Implements a proposed change from{' '} + + {props.schemaProposalTitle} + + . +
+ ); +} + function ApprovedByBadge(props: { approval: FragmentType; }) { diff --git a/packages/web/app/src/components/target/proposals/change-detail.tsx b/packages/web/app/src/components/target/proposals/change-detail.tsx index cd97e9e32fb..050ff997b8b 100644 --- a/packages/web/app/src/components/target/proposals/change-detail.tsx +++ b/packages/web/app/src/components/target/proposals/change-detail.tsx @@ -6,6 +6,7 @@ import { AccordionTrigger, } from '@/components/ui/accordion'; import { Button } from '@/components/ui/button'; +import { Link } from '@/components/ui/link'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Accordion } from '@/components/v2'; import { MergeStatus } from '@/pages/target-proposal-details'; @@ -13,24 +14,71 @@ import type { Change } from '@graphql-inspector/core'; import { ComponentNoneIcon, ExclamationTriangleIcon, InfoCircledIcon } from '@radix-ui/react-icons'; import { labelize } from '../history/errors-and-changes'; +function ExpandedContent({ + organizationSlug, + projectSlug, + targetSlug, + implementedBy, + error, +}: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + implementedBy?: string; + error?: Error; +}) { + if (!error && !implementedBy) { + return <>No details available for this change.; + } + + return ( + <> + {error ?
{error.message}
: null} + {implementedBy ? ( + <> + This proposed change has been implemented.{' '} + + View full change in history + + . + + ) : null} + + ); +} + export function ProposalChangeDetail(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; change: Change; error?: Error; icon?: ReactNode; + /** This is the schema version ID that introduced this change */ + implementedBy?: string; }) { return ( - +
{labelize(props.change.message)}
{props.icon}
- - {props.error?.message ?? <>No details available for this change.} + +
@@ -38,12 +86,16 @@ export function ProposalChangeDetail(props: { } export function ChangeBlock(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; title: string; info: string; changes: Array<{ change: Change; error?: Error; mergeStatus?: MergeStatus; + implementedBy?: string; }>; }) { return ( @@ -54,7 +106,7 @@ export function ChangeBlock(props: { {props.info && }
- {props.changes.map(({ change, error, mergeStatus }, i) => { + {props.changes.map(({ change, error, mergeStatus, implementedBy }, i) => { let icon: ReactNode | undefined; if (mergeStatus === MergeStatus.CONFLICT) { icon = ( @@ -76,6 +128,10 @@ export function ChangeBlock(props: { change={change} key={`${change.type}-${change.path}-${i}`} error={error} + implementedBy={implementedBy} + organizationSlug={props.organizationSlug} + projectSlug={props.projectSlug} + targetSlug={props.targetSlug} /> ); })} diff --git a/packages/web/app/src/components/target/proposals/index.tsx b/packages/web/app/src/components/target/proposals/index.tsx index 78403bc9056..38ebed303e9 100644 --- a/packages/web/app/src/components/target/proposals/index.tsx +++ b/packages/web/app/src/components/target/proposals/index.tsx @@ -36,6 +36,14 @@ export const Proposal_ChangeFragment = graphql(/* GraphQL */ ` message(withSafeBasedOnUsageNote: false) path severityLevel + schemaProposalChangeDetails { + implementedBy { + id + } + schemaProposal { + id + } + } meta { ... on FieldArgumentDescriptionChanged { argumentName diff --git a/packages/web/app/src/pages/target-proposal-details.tsx b/packages/web/app/src/pages/target-proposal-details.tsx index ffaad66b02f..0805bc4e0c8 100644 --- a/packages/web/app/src/pages/target-proposal-details.tsx +++ b/packages/web/app/src/pages/target-proposal-details.tsx @@ -16,6 +16,8 @@ type MappedChange = { change: Change; error?: Error; mergeStatus?: MergeStatus; + implementedBy?: string; + proposedBy?: string; }; export function TargetProposalDetailsPage(props: { @@ -44,13 +46,19 @@ export function TargetProposalDetailsPage(props: { change: c, error: conflict.error, mergeStatus: MergeStatus.CONFLICT, + implementedBy: c.schemaProposalChangeDetails?.implementedBy?.id, + proposedBy: c.schemaProposalChangeDetails?.schemaProposal.id, }; } const ignored = ignoredChanges.find(({ change }) => c === change); if (ignored) { return null; } - return { change: c }; + return { + change: c, + implementedBy: c.schemaProposalChangeDetails?.implementedBy?.id, + proposedBy: c.schemaProposalChangeDetails?.schemaProposal.id, + }; }); const breaking: MappedChange[] = []; @@ -118,21 +126,33 @@ export function TargetProposalDetailsPage(props: { changes={breaking} title="Breaking Changes" info="Changes that will break existing operations." + organizationSlug={props.organizationSlug} + projectSlug={props.projectSlug} + targetSlug={props.targetSlug} />
diff --git a/packages/web/app/src/pages/target-proposal-types.ts b/packages/web/app/src/pages/target-proposal-types.ts index 0571f984134..b489687723d 100644 --- a/packages/web/app/src/pages/target-proposal-types.ts +++ b/packages/web/app/src/pages/target-proposal-types.ts @@ -4,12 +4,19 @@ import type { Proposal_ChangeFragment } from '@/components/target/proposals'; import { FragmentType } from '@/gql'; import type { Change } from '@graphql-inspector/core'; +type ChangeProposalDetails = { + schemaProposalChangeDetails: { + implementedBy?: { id: string } | null; + schemaProposal: { id: string }; + } | null; +}; + export type ServiceProposalDetails = { compositionErrors?: FragmentType; beforeSchema: GraphQLSchema | null; afterSchema: GraphQLSchema | null; buildError: Error | null; - allChanges: Change[]; + allChanges: (Change & ChangeProposalDetails)[]; // Required because the component ChangesBlock uses this fragment. rawChanges: FragmentType[]; ignoredChanges: Array<{ diff --git a/packages/web/app/src/pages/target-proposal.tsx b/packages/web/app/src/pages/target-proposal.tsx index 0ae028d63f4..826a3550ffd 100644 --- a/packages/web/app/src/pages/target-proposal.tsx +++ b/packages/web/app/src/pages/target-proposal.tsx @@ -260,7 +260,7 @@ const ProposalsContent = (props: Parameters[0] const allChanges = proposalVersion.schemaChanges?.edges .filter(c => !!c) - ?.map(({ node: change }): Change => { + ?.map(({ node: change }) => { // @todo don't useFragment here... // eslint-disable-next-line react-hooks/rules-of-hooks const c = useFragment(Proposal_ChangeFragment, change); @@ -274,7 +274,8 @@ const ProposalsContent = (props: Parameters[0] meta: c.meta, type: (c.meta && toUpperSnakeCase(c.meta?.__typename)) ?? '', // convert to upper snake path: c.path?.join('.'), - }; + schemaProposalChangeDetails: c.schemaProposalChangeDetails ?? null, + } satisfies ServiceProposalDetails['allChanges'][number]; }) ?? []; const conflictingChanges: Array<{ change: Change; error: Error }> = []; diff --git a/packages/web/app/src/pages/target-proposals-new.tsx b/packages/web/app/src/pages/target-proposals-new.tsx index f6cc2d9e221..f2f4a559044 100644 --- a/packages/web/app/src/pages/target-proposals-new.tsx +++ b/packages/web/app/src/pages/target-proposals-new.tsx @@ -610,7 +610,12 @@ function ProposalsNewContent( ) } /> - + )} @@ -619,13 +624,19 @@ function ProposalsNewContent( ); } -function ChangesTab(props: { +function ChangesTab({ + diffs, + ...slugs +}: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; diffs: Array<{ title: string; changes: Change[]; error?: string }> | null; }) { return ( - {props.diffs === null && } - {props.diffs?.length === 0 && ( + {diffs === null && } + {diffs?.length === 0 && (
No changes @@ -633,26 +644,44 @@ function ChangesTab(props: {
)} - {props.diffs?.map((changeProps, idx) => )} + {diffs?.map((changeProps, idx) => )}
); } -function DiffService(props: { title: string; changes: Change[]; error?: string }) { +function DiffService({ + title, + changes, + error, + ...slugs +}: { + title: string; + changes: Change[]; + error?: string; + organizationSlug: string; + projectSlug: string; + targetSlug: string; +}) { return (
- {props.title} + {title}
- {props.error ? ( + {error ? (
- {props.error} + {error}
) : ( - props.changes.length === 0 &&
No changes to schema yet.
+ changes.length === 0 &&
No changes to schema yet.
)} - {props.changes.map((c, changeIndex) => { - return ; + {changes.map((c, changeIndex) => { + return ( + + ); })}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea668d4cea7..fdf5cfa5e0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1101,6 +1101,9 @@ importers: '@graphql-hive/signal': specifier: 1.0.0 version: 1.0.0 + '@graphql-inspector/compare-changes': + specifier: 0.1.0-alpha-20260410200850-4134db734dce207751762bfcbe74ab87eab30fe9 + version: 0.1.0-alpha-20260410200850-4134db734dce207751762bfcbe74ab87eab30fe9(graphql@16.9.0) '@graphql-inspector/core': specifier: 7.1.2 version: 7.1.2(graphql@16.9.0) @@ -1994,6 +1997,9 @@ importers: '@graphql-hive/logger': specifier: 1.0.9 version: 1.0.9(pino@10.3.0) + '@graphql-inspector/compare-changes': + specifier: 0.1.0-alpha-20260410200850-4134db734dce207751762bfcbe74ab87eab30fe9 + version: 0.1.0-alpha-20260410200850-4134db734dce207751762bfcbe74ab87eab30fe9(graphql@16.9.0) '@graphql-inspector/core': specifier: 7.1.2 version: 7.1.2(graphql@16.9.0) @@ -4377,6 +4383,12 @@ packages: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 yargs: 17.7.2 + '@graphql-inspector/compare-changes@0.1.0-alpha-20260410200850-4134db734dce207751762bfcbe74ab87eab30fe9': + resolution: {integrity: sha512-pv/2OBpbGBW31Vr6umW/RRarQ1QMBRbgDyOKQgFa+IfRla1s3nvO2n8g3Cn8D16hJQStk8HYnDtz0CoxiUl1Bg==} + engines: {node: '>=18.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 + '@graphql-inspector/config@4.0.2': resolution: {integrity: sha512-fnIwVpGM5AtTr4XyV8NJkDnwpXxZSBzi3BopjuXwBPXXD1F3tcVkCKNT6/5WgUQGfNPskBVbitcOPtM4hIYAOQ==} engines: {node: '>=16.0.0'} @@ -22848,6 +22860,11 @@ snapshots: tslib: 2.6.2 yargs: 17.7.2 + '@graphql-inspector/compare-changes@0.1.0-alpha-20260410200850-4134db734dce207751762bfcbe74ab87eab30fe9(graphql@16.9.0)': + dependencies: + graphql: 16.9.0 + tslib: 2.6.2 + '@graphql-inspector/config@4.0.2(graphql@16.9.0)': dependencies: graphql: 16.9.0