diff --git a/.changeset/flower-ball-gulp.md b/.changeset/flower-ball-gulp.md new file mode 100644 index 00000000000..20c34415323 --- /dev/null +++ b/.changeset/flower-ball-gulp.md @@ -0,0 +1,5 @@ +--- +'hive': minor +--- + +Show affected app deployments for breaking schema changes. When a schema check detects breaking changes, it now shows which active app deployments would be affected, including the specific operations within each deployment that use the affected schema coordinates. diff --git a/.changeset/jdpj-gvmv-utrp.md b/.changeset/jdpj-gvmv-utrp.md new file mode 100644 index 00000000000..f651a0e8e7a --- /dev/null +++ b/.changeset/jdpj-gvmv-utrp.md @@ -0,0 +1,14 @@ +--- +'hive': minor +--- + +Add `activeAppDeployments` GraphQL query to find app deployments based on usage criteria. + +New filter options: +- `lastUsedBefore`: Find stale deployments that were used but not recently (OR with neverUsedAndCreatedBefore) +- `neverUsedAndCreatedBefore`: Find old deployments that have never been used (OR with lastUsedBefore) +- `name`: Filter by app deployment name (case-insensitive partial match, AND with date filters) + +Also adds `createdAt` field to the `AppDeployment` type. + +See [Finding Stale App Deployments](https://the-guild.dev/graphql/hive/docs/schema-registry/app-deployments#finding-stale-app-deployments) for more details. diff --git a/integration-tests/testkit/cli.ts b/integration-tests/testkit/cli.ts index d5269d5e4d1..d0e50ff1825 100644 --- a/integration-tests/testkit/cli.ts +++ b/integration-tests/testkit/cli.ts @@ -80,6 +80,22 @@ async function dev(args: string[]) { ); } +export async function appCreate(args: string[]) { + const registryAddress = await getServiceHost('server', 8082); + + return await exec( + ['app:create', `--registry.endpoint`, `http://${registryAddress}/graphql`, ...args].join(' '), + ); +} + +export async function appPublish(args: string[]) { + const registryAddress = await getServiceHost('server', 8082); + + return await exec( + ['app:publish', `--registry.endpoint`, `http://${registryAddress}/graphql`, ...args].join(' '), + ); +} + export function createCLI(tokens: { readwrite: string; readonly: string }) { let publishCount = 0; diff --git a/integration-tests/tests/api/app-deployments.spec.ts b/integration-tests/tests/api/app-deployments.spec.ts index c22946b9a81..639c873a36b 100644 --- a/integration-tests/tests/api/app-deployments.spec.ts +++ b/integration-tests/tests/api/app-deployments.spec.ts @@ -1,5 +1,6 @@ import { buildASTSchema, parse } from 'graphql'; import { createLogger } from 'graphql-yoga'; +import { pollFor } from 'testkit/flow'; import { initSeed } from 'testkit/seed'; import { getServiceHost } from 'testkit/utils'; import { createHive } from '@graphql-hive/core'; @@ -43,6 +44,37 @@ const GetAppDeployment = graphql(` } `); +const GetActiveAppDeployments = graphql(` + query GetActiveAppDeployments( + $targetSelector: TargetSelectorInput! + $filter: ActiveAppDeploymentsFilter! + $first: Int + $after: String + ) { + target(reference: { bySelector: $targetSelector }) { + activeAppDeployments(filter: $filter, first: $first, after: $after) { + edges { + cursor + node { + id + name + version + status + createdAt + lastUsed + } + } + pageInfo { + hasNextPage + hasPreviousPage + endCursor + startCursor + } + } + } + } +`); + const AddDocumentsToAppDeployment = graphql(` mutation AddDocumentsToAppDeployment($input: AddDocumentsToAppDeploymentInput!) { addDocumentsToAppDeployment(input: $input) { @@ -513,14 +545,19 @@ test('create app deployment fails without feature flag enabled for organization' authToken: token.secret, }).then(res => res.expectNoGraphQLErrors()); - expect(createAppDeployment).toEqual({ - error: { - details: null, - message: - 'This organization has no access to app deployments. Please contact the Hive team for early access.', - }, - ok: null, - }); + // When FEATURE_FLAGS_APP_DEPLOYMENTS_ENABLED=1 globally, the per-org check is bypassed + if (createAppDeployment.ok) { + expect(createAppDeployment.ok.createdAppDeployment).toBeDefined(); + } else { + expect(createAppDeployment).toEqual({ + error: { + details: null, + message: + 'This organization has no access to app deployments. Please contact the Hive team for early access.', + }, + ok: null, + }); + } }); test('add documents to app deployment fails if there is no initial schema published', async () => { @@ -979,14 +1016,11 @@ test('add documents to app deployment fails without feature flag enabled for org authToken: token.secret, }).then(res => res.expectNoGraphQLErrors()); - expect(addDocumentsToAppDeployment).toEqual({ - error: { - details: null, - message: - 'This organization has no access to app deployments. Please contact the Hive team for early access.', - }, - ok: null, - }); + // When FEATURE_FLAGS_APP_DEPLOYMENTS_ENABLED=1 globally, the per-org check is bypassed. + expect(addDocumentsToAppDeployment.error?.message).toMatch( + /no access to app deployments|App deployment not found/, + ); + expect(addDocumentsToAppDeployment.ok).toBeNull(); }); test('activate app deployment fails if app deployment does not exist', async () => { @@ -1411,13 +1445,11 @@ test('retire app deployments fails without feature flag enabled for organization authToken: token.secret, }).then(res => res.expectNoGraphQLErrors()); - expect(retireAppDeployment).toEqual({ - error: { - message: - 'This organization has no access to app deployments. Please contact the Hive team for early access.', - }, - ok: null, - }); + // When FEATURE_FLAGS_APP_DEPLOYMENTS_ENABLED=1 globally, the per-org check is bypassed. + expect(retireAppDeployment.error?.message).toMatch( + /no access to app deployments|App deployment not found/, + ); + expect(retireAppDeployment.ok).toBeNull(); }); test('get app deployment documents via GraphQL API', async () => { @@ -1835,3 +1867,2405 @@ test('app deployment usage reporting', async () => { }).then(res => res.expectNoGraphQLErrors()); expect(data.target?.appDeployment?.lastUsed).toEqual(expect.any(String)); }); + +test('activeAppDeployments returns empty list when no active deployments exist', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { project, target } = await createProject(); + + const result = await execute({ + document: GetActiveAppDeployments, + variables: { + targetSelector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + filter: { + neverUsedAndCreatedBefore: new Date().toISOString(), + }, + }, + authToken: ownerToken, + }).then(res => res.expectNoGraphQLErrors()); + + expect(result.target?.activeAppDeployments).toEqual({ + edges: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + endCursor: '', + startCursor: '', + }, + }); +}); + +test('activeAppDeployments filters by neverUsedAndCreatedBefore', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { createTargetAccessToken, project, target } = await createProject(); + const token = await createTargetAccessToken({}); + + // Create and activate an app deployment + await execute({ + document: CreateAppDeployment, + variables: { + input: { + appName: 'unused-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { + input: { + appName: 'unused-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // Query for deployments never used and created before tomorrow + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const result = await execute({ + document: GetActiveAppDeployments, + variables: { + targetSelector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + filter: { + neverUsedAndCreatedBefore: tomorrow.toISOString(), + }, + }, + authToken: ownerToken, + }).then(res => res.expectNoGraphQLErrors()); + + expect(result.target?.activeAppDeployments.edges).toHaveLength(1); + expect(result.target?.activeAppDeployments.edges[0].node).toMatchObject({ + name: 'unused-app', + version: '1.0.0', + status: 'active', + lastUsed: null, + }); + expect(result.target?.activeAppDeployments.edges[0].node.createdAt).toBeTruthy(); +}); + +test('activeAppDeployments filters by name', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { createTargetAccessToken, project, target } = await createProject(); + const token = await createTargetAccessToken({}); + + // Create and activate multiple app deployments + for (const appName of ['frontend-app', 'backend-app', 'mobile-app']) { + await execute({ + document: CreateAppDeployment, + variables: { + input: { + appName, + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { + input: { + appName, + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + } + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + // Query for deployments with 'front' in the name + const result = await execute({ + document: GetActiveAppDeployments, + variables: { + targetSelector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + filter: { + name: 'front', + neverUsedAndCreatedBefore: tomorrow.toISOString(), + }, + }, + authToken: ownerToken, + }).then(res => res.expectNoGraphQLErrors()); + + expect(result.target?.activeAppDeployments.edges).toHaveLength(1); + expect(result.target?.activeAppDeployments.edges[0].node.name).toBe('frontend-app'); +}); + +test('activeAppDeployments does not return pending or retired deployments', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { createTargetAccessToken, project, target } = await createProject(); + const token = await createTargetAccessToken({}); + + // Create a pending deployment (not activated) + await execute({ + document: CreateAppDeployment, + variables: { + input: { + appName: 'pending-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // Create and activate, then retire a deployment + await execute({ + document: CreateAppDeployment, + variables: { + input: { + appName: 'retired-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { + input: { + appName: 'retired-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: RetireAppDeployment, + variables: { + input: { + target: { byId: target.id }, + appName: 'retired-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // Create and activate an active deployment + await execute({ + document: CreateAppDeployment, + variables: { + input: { + appName: 'active-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { + input: { + appName: 'active-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + // Query should only return the active deployment + const result = await execute({ + document: GetActiveAppDeployments, + variables: { + targetSelector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + filter: { + neverUsedAndCreatedBefore: tomorrow.toISOString(), + }, + }, + authToken: ownerToken, + }).then(res => res.expectNoGraphQLErrors()); + + expect(result.target?.activeAppDeployments.edges).toHaveLength(1); + expect(result.target?.activeAppDeployments.edges[0].node.name).toBe('active-app'); + expect(result.target?.activeAppDeployments.edges[0].node.status).toBe('active'); +}); + +test('activeAppDeployments filters by lastUsedBefore', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { createTargetAccessToken, project, target, waitForOperationsCollected } = + await createProject(); + const token = await createTargetAccessToken({}); + + const sdl = /* GraphQL */ ` + type Query { + hello: String + } + `; + + await token.publishSchema({ sdl }); + + // Create and activate an app deployment + await execute({ + document: CreateAppDeployment, + variables: { + input: { + appName: 'used-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: AddDocumentsToAppDeployment, + variables: { + input: { + appName: 'used-app', + appVersion: '1.0.0', + documents: [ + { + hash: 'hash', + body: 'query { hello }', + }, + ], + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { + input: { + appName: 'used-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // Report usage for this deployment + const usageAddress = await getServiceHost('usage', 8081); + + const client = createHive({ + enabled: true, + token: token.secret, + usage: true, + debug: false, + agent: { + logger: createLogger('debug'), + maxSize: 1, + }, + selfHosting: { + usageEndpoint: 'http://' + usageAddress, + graphqlEndpoint: 'http://noop/', + applicationUrl: 'http://noop/', + }, + }); + + const request = new Request('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'x-graphql-client-name': 'used-app', + 'x-graphql-client-version': '1.0.0', + }, + }); + + await client.collectUsage()( + { + document: parse(`query { hello }`), + schema: buildASTSchema(parse(sdl)), + contextValue: { request }, + }, + {}, + 'used-app~1.0.0~hash', + ); + + await waitForOperationsCollected(1); + + // Query for deployments last used before tomorrow (should include our deployment) + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const result = await execute({ + document: GetActiveAppDeployments, + variables: { + targetSelector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + filter: { + lastUsedBefore: tomorrow.toISOString(), + }, + }, + authToken: ownerToken, + }).then(res => res.expectNoGraphQLErrors()); + + expect(result.target?.activeAppDeployments.edges).toHaveLength(1); + expect(result.target?.activeAppDeployments.edges[0].node).toMatchObject({ + name: 'used-app', + version: '1.0.0', + status: 'active', + }); + expect(result.target?.activeAppDeployments.edges[0].node.lastUsed).toBeTruthy(); + + // Query for deployments last used before yesterday (should NOT include our deployment) + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + const result2 = await execute({ + document: GetActiveAppDeployments, + variables: { + targetSelector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + filter: { + lastUsedBefore: yesterday.toISOString(), + }, + }, + authToken: ownerToken, + }).then(res => res.expectNoGraphQLErrors()); + + expect(result2.target?.activeAppDeployments.edges).toHaveLength(0); +}); + +test('activeAppDeployments applies OR logic between lastUsedBefore and neverUsedAndCreatedBefore', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { createTargetAccessToken, project, target, waitForOperationsCollected } = + await createProject(); + const token = await createTargetAccessToken({}); + + const sdl = /* GraphQL */ ` + type Query { + hello: String + } + `; + + await token.publishSchema({ sdl }); + + // Create deployment 1: will be used (matches lastUsedBefore) + await execute({ + document: CreateAppDeployment, + variables: { + input: { + appName: 'used-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: AddDocumentsToAppDeployment, + variables: { + input: { + appName: 'used-app', + appVersion: '1.0.0', + documents: [{ hash: 'hash1', body: 'query { hello }' }], + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { + input: { + appName: 'used-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // Create deployment 2: will never be used (matches neverUsedAndCreatedBefore) + await execute({ + document: CreateAppDeployment, + variables: { + input: { + appName: 'unused-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { + input: { + appName: 'unused-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // Report usage for 'used-app' only + const usageAddress = await getServiceHost('usage', 8081); + + const client = createHive({ + enabled: true, + token: token.secret, + usage: true, + debug: false, + agent: { + logger: createLogger('debug'), + maxSize: 1, + }, + selfHosting: { + usageEndpoint: 'http://' + usageAddress, + graphqlEndpoint: 'http://noop/', + applicationUrl: 'http://noop/', + }, + }); + + const request = new Request('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'x-graphql-client-name': 'used-app', + 'x-graphql-client-version': '1.0.0', + }, + }); + + await client.collectUsage()( + { + document: parse(`query { hello }`), + schema: buildASTSchema(parse(sdl)), + contextValue: { request }, + }, + {}, + 'used-app~1.0.0~hash1', + ); + + await waitForOperationsCollected(1); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const result = await execute({ + document: GetActiveAppDeployments, + variables: { + targetSelector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + filter: { + lastUsedBefore: tomorrow.toISOString(), + neverUsedAndCreatedBefore: tomorrow.toISOString(), + }, + }, + authToken: ownerToken, + }).then(res => res.expectNoGraphQLErrors()); + + // Both deployments should match via OR logic + expect(result.target?.activeAppDeployments.edges).toHaveLength(2); + const names = result.target?.activeAppDeployments.edges.map(e => e.node.name).sort(); + expect(names).toEqual(['unused-app', 'used-app']); + + // Verify one has lastUsed and one doesn't + const usedApp = result.target?.activeAppDeployments.edges.find(e => e.node.name === 'used-app'); + const unusedApp = result.target?.activeAppDeployments.edges.find( + e => e.node.name === 'unused-app', + ); + expect(usedApp?.node.lastUsed).toBeTruthy(); + expect(unusedApp?.node.lastUsed).toBeNull(); +}); + +test('activeAppDeployments pagination with first and after', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { createTargetAccessToken, project, target } = await createProject(); + const token = await createTargetAccessToken({}); + + // Create 5 active deployments + for (let i = 1; i <= 5; i++) { + await execute({ + document: CreateAppDeployment, + variables: { + input: { + appName: `app-${i}`, + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { + input: { + appName: `app-${i}`, + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + } + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + // Query with first: 2 + const result1 = await execute({ + document: GetActiveAppDeployments, + variables: { + targetSelector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + filter: { + neverUsedAndCreatedBefore: tomorrow.toISOString(), + }, + first: 2, + }, + authToken: ownerToken, + }).then(res => res.expectNoGraphQLErrors()); + + expect(result1.target?.activeAppDeployments.edges).toHaveLength(2); + expect(result1.target?.activeAppDeployments.pageInfo.hasNextPage).toBe(true); + expect(result1.target?.activeAppDeployments.pageInfo.endCursor).toBeTruthy(); + + // Query with after cursor to get next page + const endCursor = result1.target?.activeAppDeployments.pageInfo.endCursor; + + const result2 = await execute({ + document: GetActiveAppDeployments, + variables: { + targetSelector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + filter: { + neverUsedAndCreatedBefore: tomorrow.toISOString(), + }, + first: 2, + after: endCursor, + }, + authToken: ownerToken, + }).then(res => res.expectNoGraphQLErrors()); + + expect(result2.target?.activeAppDeployments.edges).toHaveLength(2); + expect(result2.target?.activeAppDeployments.pageInfo.hasNextPage).toBe(true); + expect(result2.target?.activeAppDeployments.pageInfo.hasPreviousPage).toBe(true); + + // Get the last page + const endCursor2 = result2.target?.activeAppDeployments.pageInfo.endCursor; + + const result3 = await execute({ + document: GetActiveAppDeployments, + variables: { + targetSelector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + filter: { + neverUsedAndCreatedBefore: tomorrow.toISOString(), + }, + first: 2, + after: endCursor2, + }, + authToken: ownerToken, + }).then(res => res.expectNoGraphQLErrors()); + + expect(result3.target?.activeAppDeployments.edges).toHaveLength(1); + expect(result3.target?.activeAppDeployments.pageInfo.hasNextPage).toBe(false); + + // Verify we got all 5 unique apps across all pages + const allNames = [ + ...result1.target!.activeAppDeployments.edges.map(e => e.node.name), + ...result2.target!.activeAppDeployments.edges.map(e => e.node.name), + ...result3.target!.activeAppDeployments.edges.map(e => e.node.name), + ]; + expect(allNames).toHaveLength(5); + expect(new Set(allNames).size).toBe(5); +}); + +test('activeAppDeployments returns error for invalid date filter', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, organization, setFeatureFlag } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { target, project } = await createProject(); + + // DateTime scalar rejects invalid date strings at the GraphQL level + const result = await execute({ + document: GetActiveAppDeployments, + variables: { + targetSelector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + filter: { + lastUsedBefore: 'not-a-valid-date', + }, + }, + authToken: ownerToken, + }); + + expect(result.rawBody.errors).toBeDefined(); + expect(result.rawBody.errors?.[0]?.message).toMatch(/DateTime|Invalid|date/i); +}); + +test('activeAppDeployments filters by name combined with lastUsedBefore', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { createTargetAccessToken, project, target, waitForOperationsCollected } = + await createProject(); + const token = await createTargetAccessToken({}); + + const sdl = /* GraphQL */ ` + type Query { + hello: String + } + `; + + await token.publishSchema({ sdl }); + + // Create frontend-app + await execute({ + document: CreateAppDeployment, + variables: { input: { appName: 'frontend-app', appVersion: '1.0.0' } }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: AddDocumentsToAppDeployment, + variables: { + input: { + appName: 'frontend-app', + appVersion: '1.0.0', + documents: [{ hash: 'hash1', body: 'query { hello }' }], + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { input: { appName: 'frontend-app', appVersion: '1.0.0' } }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // Create backend-app + await execute({ + document: CreateAppDeployment, + variables: { input: { appName: 'backend-app', appVersion: '1.0.0' } }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: AddDocumentsToAppDeployment, + variables: { + input: { + appName: 'backend-app', + appVersion: '1.0.0', + documents: [{ hash: 'hash2', body: 'query { hello }' }], + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { input: { appName: 'backend-app', appVersion: '1.0.0' } }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // Report usage for frontend-app only + const usageAddress = await getServiceHost('usage', 8081); + + const client = createHive({ + enabled: true, + token: token.secret, + usage: true, + debug: false, + agent: { + logger: createLogger('debug'), + maxSize: 1, + }, + selfHosting: { + usageEndpoint: 'http://' + usageAddress, + graphqlEndpoint: 'http://noop/', + applicationUrl: 'http://noop/', + }, + }); + + const request = new Request('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'x-graphql-client-name': 'frontend-app', + 'x-graphql-client-version': '1.0.0', + }, + }); + + await client.collectUsage()( + { + document: parse(`query { hello }`), + schema: buildASTSchema(parse(sdl)), + contextValue: { request }, + }, + {}, + 'frontend-app~1.0.0~hash1', + ); + + await waitForOperationsCollected(1); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + // Filter by name "frontend" AND lastUsedBefore tomorrow should get frontend-app + const result = await execute({ + document: GetActiveAppDeployments, + variables: { + targetSelector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + filter: { + name: 'frontend', + lastUsedBefore: tomorrow.toISOString(), + }, + }, + authToken: ownerToken, + }).then(res => res.expectNoGraphQLErrors()); + + expect(result.target?.activeAppDeployments.edges).toHaveLength(1); + expect(result.target?.activeAppDeployments.edges[0]?.node.name).toBe('frontend-app'); +}); + +test('activeAppDeployments check pagination clamp', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { createTargetAccessToken, project, target } = await createProject(); + const token = await createTargetAccessToken({}); + + await token.publishSchema({ + sdl: /* GraphQL */ ` + type Query { + hello: String + } + `, + }); + + // Create 25 active app deployments + for (let i = 0; i < 25; i++) { + const appName = `app-${i.toString().padStart(2, '0')}`; + await execute({ + document: CreateAppDeployment, + variables: { input: { appName, appVersion: '1.0.0' } }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: AddDocumentsToAppDeployment, + variables: { + input: { + appName, + appVersion: '1.0.0', + documents: [{ hash: `hash-${i}`, body: 'query { hello }' }], + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { input: { appName, appVersion: '1.0.0' } }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + } + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + // Request 100 items, should only get 20 (max limit) + const result = await execute({ + document: GetActiveAppDeployments, + variables: { + targetSelector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + filter: { + neverUsedAndCreatedBefore: tomorrow.toISOString(), + }, + first: 100, + }, + authToken: ownerToken, + }).then(res => res.expectNoGraphQLErrors()); + + // Should be clamped to 20 + expect(result.target?.activeAppDeployments.edges).toHaveLength(20); + expect(result.target?.activeAppDeployments.pageInfo.hasNextPage).toBe(true); +}); + +const SchemaCheckWithAffectedAppDeployments = graphql(` + query SchemaCheckWithAffectedAppDeployments( + $organizationSlug: String! + $projectSlug: String! + $targetSlug: String! + $schemaCheckId: ID! + ) { + target( + reference: { + bySelector: { + organizationSlug: $organizationSlug + projectSlug: $projectSlug + targetSlug: $targetSlug + } + } + ) { + schemaCheck(id: $schemaCheckId) { + id + breakingSchemaChanges { + edges { + node { + message + path + isSafeBasedOnUsage + affectedAppDeployments { + id + name + version + affectedOperations { + hash + name + } + } + } + } + } + } + } + } +`); + +test('schema check shows affected app deployments for breaking changes', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { project, target, createTargetAccessToken } = await createProject(); + const token = await createTargetAccessToken({}); + + const publishResult = await execute({ + document: graphql(` + mutation PublishSchemaForAffectedAppDeployments($input: SchemaPublishInput!) { + schemaPublish(input: $input) { + __typename + ... on SchemaPublishSuccess { + valid + } + ... on SchemaPublishError { + valid + } + } + } + `), + variables: { + input: { + sdl: /* GraphQL */ ` + type Query { + hello: String + world: String + } + `, + author: 'test-author', + commit: 'test-commit', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + expect(publishResult.schemaPublish.__typename).toBe('SchemaPublishSuccess'); + + await execute({ + document: CreateAppDeployment, + variables: { + input: { + appName: 'test-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: AddDocumentsToAppDeployment, + variables: { + input: { + appName: 'test-app', + appVersion: '1.0.0', + documents: [ + { + hash: 'hello-query-hash', + body: 'query GetHello { hello }', + }, + { + hash: 'world-query-hash', + body: 'query GetWorld { world }', + }, + ], + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { + input: { + appName: 'test-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let schemaCheckData: any = null; + + // ClickHouse eventual consistency + await pollFor( + async () => { + const checkResult = await execute({ + document: graphql(` + mutation SchemaCheckForAffectedAppDeploymentsPoll($input: SchemaCheckInput!) { + schemaCheck(input: $input) { + __typename + ... on SchemaCheckSuccess { + schemaCheck { + id + } + } + ... on SchemaCheckError { + schemaCheck { + id + } + } + } + } + `), + variables: { + input: { + sdl: /* GraphQL */ ` + type Query { + world: String + } + `, + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') { + return false; + } + + const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id; + if (!schemaCheckId) { + return false; + } + + schemaCheckData = await execute({ + document: SchemaCheckWithAffectedAppDeployments, + variables: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + schemaCheckId, + }, + authToken: ownerToken, + }); + + const breakingChanges = + schemaCheckData.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges; + + // Check if the hello field removal has affectedAppDeployments + const helloFieldRemoval = breakingChanges?.find((edge: { node: { message: string } }) => + edge.node.message.includes('hello'), + ); + return !!(helloFieldRemoval?.node.affectedAppDeployments?.length ?? 0); + }, + { maxWait: 15_000 }, + ); + + const breakingChanges = + schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges; + + // console.log('breakingChanges:', JSON.stringify(breakingChanges, null, 2)); + + expect(breakingChanges).toBeDefined(); + expect(breakingChanges!.length).toBeGreaterThan(0); + + const helloFieldRemoval = breakingChanges!.find((edge: { node: { message: string } }) => + edge.node.message.includes('hello'), + ); + + // console.log('helloFieldRemoval:', JSON.stringify(helloFieldRemoval, null, 2)); + + expect(helloFieldRemoval).toBeDefined(); + expect(helloFieldRemoval?.node.affectedAppDeployments).toBeDefined(); + expect(helloFieldRemoval?.node.affectedAppDeployments?.length).toBe(1); + + const affectedDeployment = helloFieldRemoval?.node.affectedAppDeployments?.[0]; + expect(affectedDeployment?.name).toBe('test-app'); + expect(affectedDeployment?.version).toBe('1.0.0'); + expect(affectedDeployment?.affectedOperations).toBeDefined(); + expect(affectedDeployment?.affectedOperations.length).toBe(1); + expect(affectedDeployment?.affectedOperations[0].hash).toBe('hello-query-hash'); + expect(affectedDeployment?.affectedOperations[0].name).toBe('GetHello'); +}); + +test('breaking changes show only deployments affected by their specific coordinate', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { project, target, createTargetAccessToken } = await createProject(); + const token = await createTargetAccessToken({}); + + const publishResult = await execute({ + document: graphql(` + mutation PublishSchemaForCoordinateTest($input: SchemaPublishInput!) { + schemaPublish(input: $input) { + __typename + ... on SchemaPublishSuccess { + valid + } + ... on SchemaPublishError { + valid + } + } + } + `), + variables: { + input: { + sdl: /* GraphQL */ ` + type Query { + hello: String + world: String + foo: String + } + `, + author: 'test-author', + commit: 'test-commit', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + expect(publishResult.schemaPublish.__typename).toBe('SchemaPublishSuccess'); + + await execute({ + document: CreateAppDeployment, + variables: { + input: { + appName: 'app-a', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: AddDocumentsToAppDeployment, + variables: { + input: { + appName: 'app-a', + appVersion: '1.0.0', + documents: [ + { + hash: 'app-a-hello-hash', + body: 'query AppAHello { hello }', + }, + ], + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { + input: { + appName: 'app-a', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: CreateAppDeployment, + variables: { + input: { + appName: 'app-b', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: AddDocumentsToAppDeployment, + variables: { + input: { + appName: 'app-b', + appVersion: '1.0.0', + documents: [ + { + hash: 'app-b-world-hash', + body: 'query AppBWorld { world }', + }, + ], + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { + input: { + appName: 'app-b', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let schemaCheckData: any = null; + + await pollFor( + async () => { + const checkResult = await execute({ + document: graphql(` + mutation SchemaCheckForCoordinateTestPoll($input: SchemaCheckInput!) { + schemaCheck(input: $input) { + __typename + ... on SchemaCheckSuccess { + schemaCheck { + id + } + } + ... on SchemaCheckError { + schemaCheck { + id + } + } + } + } + `), + variables: { + input: { + sdl: /* GraphQL */ ` + type Query { + foo: String + } + `, + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') { + return false; + } + + const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id; + if (!schemaCheckId) { + return false; + } + + schemaCheckData = await execute({ + document: SchemaCheckWithAffectedAppDeployments, + variables: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + schemaCheckId, + }, + authToken: ownerToken, + }); + + const breakingChanges = + schemaCheckData.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges; + + // Check if both breaking changes have affectedAppDeployments + const helloRemoval = breakingChanges?.find((edge: { node: { message: string } }) => + edge.node.message.includes('hello'), + ); + const worldRemoval = breakingChanges?.find((edge: { node: { message: string } }) => + edge.node.message.includes('world'), + ); + return !!( + (helloRemoval?.node.affectedAppDeployments?.length ?? 0) && + (worldRemoval?.node.affectedAppDeployments?.length ?? 0) + ); + }, + { maxWait: 15_000 }, + ); + + const breakingChanges = + schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges; + + expect(breakingChanges).toBeDefined(); + expect(breakingChanges!.length).toBe(2); + + const helloRemoval = breakingChanges!.find((edge: { node: { message: string } }) => + edge.node.message.includes('hello'), + ); + const worldRemoval = breakingChanges!.find((edge: { node: { message: string } }) => + edge.node.message.includes('world'), + ); + + // Verify hello removal only shows App A (not App B) + expect(helloRemoval).toBeDefined(); + expect(helloRemoval?.node.affectedAppDeployments?.length).toBe(1); + expect(helloRemoval?.node.affectedAppDeployments?.[0].name).toBe('app-a'); + expect(helloRemoval?.node.affectedAppDeployments?.[0].affectedOperations.length).toBe(1); + expect(helloRemoval?.node.affectedAppDeployments?.[0].affectedOperations[0].hash).toBe( + 'app-a-hello-hash', + ); + + // Verify world removal only shows App B (not App A) + expect(worldRemoval).toBeDefined(); + expect(worldRemoval?.node.affectedAppDeployments?.length).toBe(1); + expect(worldRemoval?.node.affectedAppDeployments?.[0].name).toBe('app-b'); + expect(worldRemoval?.node.affectedAppDeployments?.[0].affectedOperations.length).toBe(1); + expect(worldRemoval?.node.affectedAppDeployments?.[0].affectedOperations[0].hash).toBe( + 'app-b-world-hash', + ); +}); + +test('retired app deployments are excluded from affected deployments', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { project, target, createTargetAccessToken } = await createProject(); + const token = await createTargetAccessToken({}); + + // Publish schema + await execute({ + document: graphql(` + mutation PublishSchemaForRetiredTest($input: SchemaPublishInput!) { + schemaPublish(input: $input) { + __typename + } + } + `), + variables: { + input: { + sdl: /* GraphQL */ ` + type Query { + hello: String + } + `, + author: 'test-author', + commit: 'test-commit', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // Create and activate app deployment + await execute({ + document: CreateAppDeployment, + variables: { + input: { + appName: 'retired-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: AddDocumentsToAppDeployment, + variables: { + input: { + appName: 'retired-app', + appVersion: '1.0.0', + documents: [ + { + hash: 'retired-app-hash', + body: 'query GetHello { hello }', + }, + ], + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { + input: { + appName: 'retired-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // Retire the app deployment + await execute({ + document: RetireAppDeployment, + variables: { + input: { + appName: 'retired-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let schemaCheckData: any = null; + + // Schema check that removes hello field - retired deployment should NOT appear + await pollFor( + async () => { + const checkResult = await execute({ + document: graphql(` + mutation SchemaCheckForRetiredTestPoll($input: SchemaCheckInput!) { + schemaCheck(input: $input) { + __typename + ... on SchemaCheckError { + schemaCheck { + id + } + } + } + } + `), + variables: { + input: { + sdl: /* GraphQL */ ` + type Query { + world: String + } + `, + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') { + return false; + } + + const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id; + if (!schemaCheckId) { + return false; + } + + schemaCheckData = await execute({ + document: SchemaCheckWithAffectedAppDeployments, + variables: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + schemaCheckId, + }, + authToken: ownerToken, + }); + + return true; + }, + { maxWait: 15_000 }, + ); + + const breakingChanges = + schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges; + + expect(breakingChanges).toBeDefined(); + expect(breakingChanges!.length).toBeGreaterThan(0); + + const helloRemoval = breakingChanges!.find((edge: { node: { message: string } }) => + edge.node.message.includes('hello'), + ); + + // Retired deployment should NOT appear in affected deployments + expect(helloRemoval).toBeDefined(); + expect(helloRemoval?.node.affectedAppDeployments).toEqual([]); +}); + +test('pending (non-activated) app deployments are excluded from affected deployments', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { project, target, createTargetAccessToken } = await createProject(); + const token = await createTargetAccessToken({}); + + // Publish schema + await execute({ + document: graphql(` + mutation PublishSchemaForPendingTest($input: SchemaPublishInput!) { + schemaPublish(input: $input) { + __typename + } + } + `), + variables: { + input: { + sdl: /* GraphQL */ ` + type Query { + hello: String + } + `, + author: 'test-author', + commit: 'test-commit', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // Create app deployment but DO NOT activate it + await execute({ + document: CreateAppDeployment, + variables: { + input: { + appName: 'pending-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: AddDocumentsToAppDeployment, + variables: { + input: { + appName: 'pending-app', + appVersion: '1.0.0', + documents: [ + { + hash: 'pending-app-hash', + body: 'query GetHello { hello }', + }, + ], + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // Note: NOT activating the deployment + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let schemaCheckData: any = null; + + // Schema check that removes hello field - pending deployment should NOT appear + await pollFor( + async () => { + const checkResult = await execute({ + document: graphql(` + mutation SchemaCheckForPendingTestPoll($input: SchemaCheckInput!) { + schemaCheck(input: $input) { + __typename + ... on SchemaCheckError { + schemaCheck { + id + } + } + } + } + `), + variables: { + input: { + sdl: /* GraphQL */ ` + type Query { + world: String + } + `, + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') { + return false; + } + + const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id; + if (!schemaCheckId) { + return false; + } + + schemaCheckData = await execute({ + document: SchemaCheckWithAffectedAppDeployments, + variables: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + schemaCheckId, + }, + authToken: ownerToken, + }); + + return true; + }, + { maxWait: 15_000 }, + ); + + const breakingChanges = + schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges; + + expect(breakingChanges).toBeDefined(); + expect(breakingChanges!.length).toBeGreaterThan(0); + + const helloRemoval = breakingChanges!.find((edge: { node: { message: string } }) => + edge.node.message.includes('hello'), + ); + + // Pending (non-activated) deployment should NOT appear in affected deployments + expect(helloRemoval).toBeDefined(); + expect(helloRemoval?.node.affectedAppDeployments).toEqual([]); +}); + +test('multiple deployments affected by same breaking change all appear', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { project, target, createTargetAccessToken } = await createProject(); + const token = await createTargetAccessToken({}); + + await execute({ + document: graphql(` + mutation PublishSchemaForMultiDeploymentTest($input: SchemaPublishInput!) { + schemaPublish(input: $input) { + __typename + } + } + `), + variables: { + input: { + sdl: /* GraphQL */ ` + type Query { + hello: String + } + `, + author: 'test-author', + commit: 'test-commit', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // Create and activate App 1 - uses hello field + await execute({ + document: CreateAppDeployment, + variables: { input: { appName: 'multi-app-1', appVersion: '1.0.0' } }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: AddDocumentsToAppDeployment, + variables: { + input: { + appName: 'multi-app-1', + appVersion: '1.0.0', + documents: [{ hash: 'multi-app-1-hash', body: 'query App1Hello { hello }' }], + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { input: { appName: 'multi-app-1', appVersion: '1.0.0' } }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // Create and activate App 2 - also uses hello field + await execute({ + document: CreateAppDeployment, + variables: { input: { appName: 'multi-app-2', appVersion: '1.0.0' } }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: AddDocumentsToAppDeployment, + variables: { + input: { + appName: 'multi-app-2', + appVersion: '1.0.0', + documents: [{ hash: 'multi-app-2-hash', body: 'query App2Hello { hello }' }], + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { input: { appName: 'multi-app-2', appVersion: '1.0.0' } }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let schemaCheckData: any = null; + + await pollFor( + async () => { + const checkResult = await execute({ + document: graphql(` + mutation SchemaCheckForMultiDeploymentTestPoll($input: SchemaCheckInput!) { + schemaCheck(input: $input) { + __typename + ... on SchemaCheckError { + schemaCheck { + id + } + } + } + } + `), + variables: { + input: { + sdl: /* GraphQL */ ` + type Query { + world: String + } + `, + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') { + return false; + } + + const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id; + if (!schemaCheckId) { + return false; + } + + schemaCheckData = await execute({ + document: SchemaCheckWithAffectedAppDeployments, + variables: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + schemaCheckId, + }, + authToken: ownerToken, + }); + + const breakingChanges = + schemaCheckData.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges; + const helloRemoval = breakingChanges?.find((edge: { node: { message: string } }) => + edge.node.message.includes('hello'), + ); + // Wait until both deployments appear + return (helloRemoval?.node.affectedAppDeployments?.length ?? 0) >= 2; + }, + { maxWait: 15_000 }, + ); + + const breakingChanges = + schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges; + const helloRemoval = breakingChanges!.find((edge: { node: { message: string } }) => + edge.node.message.includes('hello'), + ); + + // Both deployments should appear + expect(helloRemoval?.node.affectedAppDeployments?.length).toBe(2); + const appNames = helloRemoval?.node.affectedAppDeployments?.map((d: { name: string }) => d.name); + expect(appNames).toContain('multi-app-1'); + expect(appNames).toContain('multi-app-2'); +}); + +test('anonymous operations (null name) are handled correctly', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { project, target, createTargetAccessToken } = await createProject(); + const token = await createTargetAccessToken({}); + + await execute({ + document: graphql(` + mutation PublishSchemaForAnonOpTest($input: SchemaPublishInput!) { + schemaPublish(input: $input) { + __typename + } + } + `), + variables: { + input: { + sdl: /* GraphQL */ ` + type Query { + hello: String + } + `, + author: 'test-author', + commit: 'test-commit', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // Create app with anonymous operation (no operation name) + await execute({ + document: CreateAppDeployment, + variables: { input: { appName: 'anon-app', appVersion: '1.0.0' } }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: AddDocumentsToAppDeployment, + variables: { + input: { + appName: 'anon-app', + appVersion: '1.0.0', + documents: [{ hash: 'anon-op-hash', body: '{ hello }' }], // Anonymous query + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { input: { appName: 'anon-app', appVersion: '1.0.0' } }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let schemaCheckData: any = null; + + await pollFor( + async () => { + const checkResult = await execute({ + document: graphql(` + mutation SchemaCheckForAnonOpTestPoll($input: SchemaCheckInput!) { + schemaCheck(input: $input) { + __typename + ... on SchemaCheckError { + schemaCheck { + id + } + } + } + } + `), + variables: { + input: { + sdl: /* GraphQL */ ` + type Query { + world: String + } + `, + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') { + return false; + } + + const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id; + if (!schemaCheckId) { + return false; + } + + schemaCheckData = await execute({ + document: SchemaCheckWithAffectedAppDeployments, + variables: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + schemaCheckId, + }, + authToken: ownerToken, + }); + + const breakingChanges = + schemaCheckData.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges; + const helloRemoval = breakingChanges?.find((edge: { node: { message: string } }) => + edge.node.message.includes('hello'), + ); + return !!(helloRemoval?.node.affectedAppDeployments?.length ?? 0); + }, + { maxWait: 15_000 }, + ); + + const breakingChanges = + schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges; + const helloRemoval = breakingChanges!.find((edge: { node: { message: string } }) => + edge.node.message.includes('hello'), + ); + + expect(helloRemoval?.node.affectedAppDeployments?.length).toBe(1); + const affectedOp = helloRemoval?.node.affectedAppDeployments?.[0].affectedOperations[0]; + expect(affectedOp.hash).toBe('anon-op-hash'); + expect(affectedOp.name).toBeNull(); // Anonymous operation has null name +}); + +test('multiple operations in same deployment affected by same change', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { project, target, createTargetAccessToken } = await createProject(); + const token = await createTargetAccessToken({}); + + await execute({ + document: graphql(` + mutation PublishSchemaForMultiOpTest($input: SchemaPublishInput!) { + schemaPublish(input: $input) { + __typename + } + } + `), + variables: { + input: { + sdl: /* GraphQL */ ` + type Query { + hello: String + } + `, + author: 'test-author', + commit: 'test-commit', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // Create app with multiple operations using hello field + await execute({ + document: CreateAppDeployment, + variables: { input: { appName: 'multi-op-app', appVersion: '1.0.0' } }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: AddDocumentsToAppDeployment, + variables: { + input: { + appName: 'multi-op-app', + appVersion: '1.0.0', + documents: [ + { hash: 'op-1-hash', body: 'query GetHello1 { hello }' }, + { hash: 'op-2-hash', body: 'query GetHello2 { hello }' }, + { hash: 'op-3-hash', body: 'query GetHello3 { hello }' }, + ], + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { input: { appName: 'multi-op-app', appVersion: '1.0.0' } }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let schemaCheckData: any = null; + + await pollFor( + async () => { + const checkResult = await execute({ + document: graphql(` + mutation SchemaCheckForMultiOpTestPoll($input: SchemaCheckInput!) { + schemaCheck(input: $input) { + __typename + ... on SchemaCheckError { + schemaCheck { + id + } + } + } + } + `), + variables: { + input: { + sdl: /* GraphQL */ ` + type Query { + world: String + } + `, + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') { + return false; + } + + const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id; + if (!schemaCheckId) { + return false; + } + + schemaCheckData = await execute({ + document: SchemaCheckWithAffectedAppDeployments, + variables: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + schemaCheckId, + }, + authToken: ownerToken, + }); + + const breakingChanges = + schemaCheckData.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges; + const helloRemoval = breakingChanges?.find((edge: { node: { message: string } }) => + edge.node.message.includes('hello'), + ); + // Wait until all 3 operations appear + return (helloRemoval?.node.affectedAppDeployments?.[0]?.affectedOperations?.length ?? 0) >= 3; + }, + { maxWait: 15_000 }, + ); + + const breakingChanges = + schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges; + const helloRemoval = breakingChanges!.find((edge: { node: { message: string } }) => + edge.node.message.includes('hello'), + ); + + expect(helloRemoval?.node.affectedAppDeployments?.length).toBe(1); + const affectedOps = helloRemoval?.node.affectedAppDeployments?.[0].affectedOperations; + expect(affectedOps.length).toBe(3); + + const opHashes = affectedOps.map((op: { hash: string }) => op.hash); + expect(opHashes).toContain('op-1-hash'); + expect(opHashes).toContain('op-2-hash'); + expect(opHashes).toContain('op-3-hash'); +}); + +test('schema check fails if breaking change affects app deployment even when usage data says safe', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { + project, + target, + createTargetAccessToken, + updateTargetValidationSettings, + waitForOperationsCollected, + } = await createProject(); + const token = await createTargetAccessToken({}); + + const sdl = /* GraphQL */ ` + type Query { + hello: String + world: String + } + `; + + await token.publishSchema({ sdl }); + + const usageReport = await token.collectUsage({ + size: 1, + map: { + 'world-op': { + operationName: 'GetWorld', + operation: 'query GetWorld { world }', + fields: ['Query', 'Query.world'], + }, + }, + operations: [ + { + operationMapKey: 'world-op', + timestamp: Date.now(), + execution: { + ok: true, + duration: 100000000, + errorsTotal: 0, + }, + metadata: { + client: { + name: 'demo', + version: '0.0.1', + }, + }, + }, + ], + }); + expect(usageReport.status).toBe(200); + await waitForOperationsCollected(1); + + await updateTargetValidationSettings({ + isEnabled: true, + percentage: 0, + }); + + const baselineCheck = await execute({ + document: graphql(` + mutation BaselineSchemaCheck($input: SchemaCheckInput!) { + schemaCheck(input: $input) { + __typename + ... on SchemaCheckSuccess { + valid + schemaCheck { + id + } + } + ... on SchemaCheckError { + valid + } + } + } + `), + variables: { + input: { + sdl: /* GraphQL */ ` + type Query { + world: String + } + `, + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + expect(baselineCheck.schemaCheck.__typename).toBe('SchemaCheckSuccess'); + expect(baselineCheck.schemaCheck).toMatchObject({ + __typename: 'SchemaCheckSuccess', + valid: true, + }); + + const baselineSchemaCheckId = (baselineCheck.schemaCheck as { schemaCheck: { id: string } }) + .schemaCheck.id; + const baselineDetails = await execute({ + document: SchemaCheckWithAffectedAppDeployments, + variables: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + schemaCheckId: baselineSchemaCheckId, + }, + authToken: ownerToken, + }); + + const baselineBreakingChanges = + baselineDetails.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges; + const baselineHelloRemoval = baselineBreakingChanges?.find( + (edge: { node: { message: string } }) => edge.node.message.includes('hello'), + ); + expect(baselineHelloRemoval?.node.isSafeBasedOnUsage).toBe(true); + expect(baselineHelloRemoval?.node.affectedAppDeployments).toEqual([]); + + await execute({ + document: CreateAppDeployment, + variables: { + input: { + appName: 'my-app', + appVersion: '2.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: AddDocumentsToAppDeployment, + variables: { + input: { + appName: 'my-app', + appVersion: '2.0.0', + documents: [ + { + hash: 'hello-query-hash', + body: 'query GetHello { hello }', + }, + ], + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { + input: { + appName: 'my-app', + appVersion: '2.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + let schemaCheckData: any = null; + + await pollFor( + async () => { + const checkResult = await execute({ + document: graphql(` + mutation SchemaCheckWithAppDeploymentOverride($input: SchemaCheckInput!) { + schemaCheck(input: $input) { + __typename + ... on SchemaCheckSuccess { + schemaCheck { + id + } + } + ... on SchemaCheckError { + schemaCheck { + id + } + } + } + } + `), + variables: { + input: { + sdl: /* GraphQL */ ` + type Query { + world: String + } + `, + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') { + return false; + } + + const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id; + if (!schemaCheckId) { + return false; + } + + schemaCheckData = await execute({ + document: SchemaCheckWithAffectedAppDeployments, + variables: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + schemaCheckId, + }, + authToken: ownerToken, + }); + + const breakingChanges = + schemaCheckData.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges; + const helloFieldRemoval = breakingChanges?.find((edge: { node: { message: string } }) => + edge.node.message.includes('hello'), + ); + return !!(helloFieldRemoval?.node.affectedAppDeployments?.length ?? 0); + }, + { maxWait: 15_000 }, + ); + + const breakingChanges = + schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges; + + expect(breakingChanges).toBeDefined(); + expect(breakingChanges!.length).toBeGreaterThan(0); + + const helloFieldRemoval = breakingChanges!.find((edge: { node: { message: string } }) => + edge.node.message.includes('hello'), + ); + + expect(helloFieldRemoval).toBeDefined(); + // The change should NOT be marked as safe because app deployment uses it + expect(helloFieldRemoval?.node.isSafeBasedOnUsage).toBe(false); + expect(helloFieldRemoval?.node.affectedAppDeployments).toBeDefined(); + expect(helloFieldRemoval?.node.affectedAppDeployments?.length).toBe(1); + + const affectedDeployment = helloFieldRemoval?.node.affectedAppDeployments?.[0]; + expect(affectedDeployment?.name).toBe('my-app'); + expect(affectedDeployment?.version).toBe('2.0.0'); + expect(affectedDeployment?.affectedOperations).toBeDefined(); + expect(affectedDeployment?.affectedOperations.length).toBe(1); + expect(affectedDeployment?.affectedOperations[0].hash).toBe('hello-query-hash'); +}); + +test('fields NOT used by app deployment remain safe based on usage', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag, organization } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { project, target, createTargetAccessToken, updateTargetValidationSettings } = + await createProject(); + const token = await createTargetAccessToken({}); + + const sdl = /* GraphQL */ ` + type Query { + hello: String + world: String + unused: String + } + `; + + await token.publishSchema({ sdl }); + + await updateTargetValidationSettings({ + isEnabled: true, + percentage: 0, + }); + + await execute({ + document: CreateAppDeployment, + variables: { + input: { + appName: 'my-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: AddDocumentsToAppDeployment, + variables: { + input: { + appName: 'my-app', + appVersion: '1.0.0', + documents: [ + { + hash: 'hello-query-hash', + body: 'query HelloQuery { hello }', + }, + ], + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + await execute({ + document: ActivateAppDeployment, + variables: { + input: { + appName: 'my-app', + appVersion: '1.0.0', + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let schemaCheckData: any = null; + + await pollFor( + async () => { + const checkResult = await execute({ + document: graphql(` + mutation InverseTestSchemaCheck($input: SchemaCheckInput!) { + schemaCheck(input: $input) { + __typename + ... on SchemaCheckSuccess { + schemaCheck { + id + } + } + ... on SchemaCheckError { + schemaCheck { + id + } + } + } + } + `), + variables: { + input: { + sdl: /* GraphQL */ ` + type Query { + world: String + } + `, + }, + }, + authToken: token.secret, + }).then(res => res.expectNoGraphQLErrors()); + + if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') { + return false; + } + + const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id; + if (!schemaCheckId) { + return false; + } + + schemaCheckData = await execute({ + document: SchemaCheckWithAffectedAppDeployments, + variables: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + schemaCheckId, + }, + authToken: ownerToken, + }); + + const breakingChanges = + schemaCheckData.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges; + const helloRemoval = breakingChanges?.find((edge: { node: { message: string } }) => + edge.node.message.includes('hello'), + ); + return !!(helloRemoval?.node.affectedAppDeployments?.length ?? 0); + }, + { maxWait: 15_000 }, + ); + + const breakingChanges = + schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges; + + expect(breakingChanges).toBeDefined(); + expect(breakingChanges!.length).toBe(2); // hello and unused + + const helloRemoval = breakingChanges!.find((edge: { node: { message: string } }) => + edge.node.message.includes('hello'), + ); + const unusedRemoval = breakingChanges!.find((edge: { node: { message: string } }) => + edge.node.message.includes('unused'), + ); + + // 'hello' should be UNSAFE because app deployment uses it + expect(helloRemoval).toBeDefined(); + expect(helloRemoval?.node.isSafeBasedOnUsage).toBe(false); + expect(helloRemoval?.node.affectedAppDeployments?.length).toBe(1); + + expect(unusedRemoval).toBeDefined(); + expect(unusedRemoval?.node.isSafeBasedOnUsage).toBe(true); + expect(unusedRemoval?.node.affectedAppDeployments?.length).toBe(0); +}); diff --git a/integration-tests/tests/api/schema/delete.spec.ts b/integration-tests/tests/api/schema/delete.spec.ts index a6a828fb1b9..fe6746fca11 100644 --- a/integration-tests/tests/api/schema/delete.spec.ts +++ b/integration-tests/tests/api/schema/delete.spec.ts @@ -197,6 +197,7 @@ test.concurrent( expect(changes[0]).toMatchInlineSnapshot(` { + affectedAppDeployments: null, approvalMetadata: null, breakingChangeSchemaCoordinate: Query.bruv, criticality: BREAKING, diff --git a/integration-tests/tests/cli/schema.spec.ts b/integration-tests/tests/cli/schema.spec.ts index 1de76d678a7..8c122587b81 100644 --- a/integration-tests/tests/cli/schema.spec.ts +++ b/integration-tests/tests/cli/schema.spec.ts @@ -1,10 +1,13 @@ /* eslint-disable no-process-env */ -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; +import { writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import stripAnsi from 'strip-ansi'; import { ProjectType, RuleInstanceSeverityLevel } from 'testkit/gql/graphql'; import * as GraphQLSchema from 'testkit/gql/graphql'; import type { CompositeSchema } from '@hive/api/__generated__/types'; -import { createCLI, schemaCheck, schemaPublish } from '../../testkit/cli'; +import { appCreate, appPublish, createCLI, schemaCheck, schemaPublish } from '../../testkit/cli'; import { cliOutputSnapshotSerializer } from '../../testkit/cli-snapshot-serializer'; import { initSeed } from '../../testkit/seed'; import { createPolicy } from '../api/policy/policy-check.spec'; @@ -1005,3 +1008,62 @@ test.concurrent( ); }, ); + +test.concurrent( + 'schema:check displays affected app deployments for breaking changes', + async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject, setFeatureFlag } = await createOrg(); + await setFeatureFlag('appDeployments', true); + const { createTargetAccessToken } = await createProject(ProjectType.Single); + const { secret } = await createTargetAccessToken({}); + + await schemaPublish([ + '--registry.accessToken', + secret, + '--author', + 'Test', + '--commit', + 'init', + 'fixtures/init-schema.graphql', + ]); + + const operationsFile = join(tmpdir(), `operations-${randomUUID()}.json`); + await writeFile( + operationsFile, + JSON.stringify({ + 'op-hash-1': 'query GetUserEmails { users { id email } }', + 'op-hash-2': 'query GetUserProfile { users { id name email } }', + }), + ); + + await appCreate([ + '--registry.accessToken', + secret, + '--name', + 'test-app', + '--version', + '1.0.0', + operationsFile, + ]); + + await appPublish([ + '--registry.accessToken', + secret, + '--name', + 'test-app', + '--version', + '1.0.0', + ]); + + try { + await schemaCheck(['--registry.accessToken', secret, 'fixtures/breaking-schema.graphql']); + expect.fail('Expected schema check to fail with breaking changes'); + } catch (error: any) { + const output = stripAnsi(error.message || error.stderr || String(error)); + expect(output).toContain('test-app@1.0.0'); + expect(output).toContain('GetUserEmails'); + expect(output).toContain('GetUserProfile'); + } + }, +); diff --git a/packages/libraries/cli/src/helpers/schema.ts b/packages/libraries/cli/src/helpers/schema.ts index 8c8eb0e8da0..51e6555be85 100644 --- a/packages/libraries/cli/src/helpers/schema.ts +++ b/packages/libraries/cli/src/helpers/schema.ts @@ -49,6 +49,14 @@ const RenderChanges_SchemaChanges = graphql(` displayName } } + affectedAppDeployments { + name + version + affectedOperations { + name + hash + } + } } } } @@ -78,6 +86,15 @@ export const renderChanges = (maskedChanges: FragmentType { + const ops = deployment.affectedOperations; + const opNames = ops.map(op => op.name ?? `unnamed (${op.hash.slice(0, 7)})`).join(', '); + t.indent( + ` ${Texture.colors.yellow('-')} ${Texture.colors.bold(`${deployment.name}@${deployment.version}`)}: ${opNames}`, + ); + }); + } }); }; diff --git a/packages/services/api/src/modules/alerts/providers/adapters/msteams.spec.ts b/packages/services/api/src/modules/alerts/providers/adapters/msteams.spec.ts index 42d20c6f96a..291db0cbde7 100644 --- a/packages/services/api/src/modules/alerts/providers/adapters/msteams.spec.ts +++ b/packages/services/api/src/modules/alerts/providers/adapters/msteams.spec.ts @@ -35,6 +35,7 @@ describe('TeamsCommunicationAdapter', () => { reason: 'Removing a field is a breaking change. It is preferable to deprecate the field before removing it.', usageStatistics: null, + affectedAppDeployments: null, breakingChangeSchemaCoordinate: 'Mutation.addFoo', }, { @@ -54,6 +55,7 @@ describe('TeamsCommunicationAdapter', () => { reason: 'Removing a field is a breaking change. It is preferable to deprecate the field before removing it.', usageStatistics: null, + affectedAppDeployments: null, breakingChangeSchemaCoordinate: 'Query.foo3', }, { @@ -71,6 +73,7 @@ describe('TeamsCommunicationAdapter', () => { isSafeBasedOnUsage: false, reason: null, usageStatistics: null, + affectedAppDeployments: null, breakingChangeSchemaCoordinate: null, }, { @@ -88,6 +91,7 @@ describe('TeamsCommunicationAdapter', () => { isSafeBasedOnUsage: false, reason: null, usageStatistics: null, + affectedAppDeployments: null, breakingChangeSchemaCoordinate: null, }, ] as Array; diff --git a/packages/services/api/src/modules/app-deployments/module.graphql.ts b/packages/services/api/src/modules/app-deployments/module.graphql.ts index dc9bfb1cd25..86f24437520 100644 --- a/packages/services/api/src/modules/app-deployments/module.graphql.ts +++ b/packages/services/api/src/modules/app-deployments/module.graphql.ts @@ -13,6 +13,10 @@ export default gql` totalDocumentCount: Int! status: AppDeploymentStatus! """ + The timestamp when the app deployment was created. + """ + createdAt: DateTime! + """ The last time a GraphQL request that used the app deployment was reported. """ lastUsed: DateTime @@ -62,6 +66,32 @@ export default gql` operationName: String } + """ + Filter options for querying active app deployments. + The date filters (lastUsedBefore, neverUsedAndCreatedBefore) use OR semantics: + a deployment is included if it matches either date condition. + If no date filters are provided, all active deployments are returned. + """ + input ActiveAppDeploymentsFilter { + """ + Filter by app deployment name. Case-insensitive partial match. + Applied with AND semantics to narrow down results. + """ + name: String + """ + Returns deployments that were last used before the given timestamp. + Useful for identifying stale or inactive deployments that have been used + at least once but not recently. + """ + lastUsedBefore: DateTime + """ + Returns deployments that have never been used and were created before + the given timestamp. Useful for identifying old, unused deployments + that may be candidates for cleanup. + """ + neverUsedAndCreatedBefore: DateTime + } + extend type Target { """ The app deployments for this target. @@ -72,6 +102,18 @@ export default gql` Whether the viewer can access the app deployments within a target. """ viewerCanViewAppDeployments: Boolean! + """ + Find active app deployments matching specific criteria. + Date filter conditions (lastUsedBefore, neverUsedAndCreatedBefore) use OR semantics. + If no date filters are provided, all active deployments are returned. + The name filter uses AND semantics to narrow results. + Only active deployments are returned (not pending or retired). + """ + activeAppDeployments( + first: Int + after: String + filter: ActiveAppDeploymentsFilter! + ): AppDeploymentConnection! } extend type Mutation { diff --git a/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts b/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts index 1992ba863ec..0c30ae1cd0e 100644 --- a/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts +++ b/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts @@ -43,6 +43,12 @@ export class AppDeploymentsManager { return appDeployment; } + async getAppDeploymentById(args: { + appDeploymentId: string; + }): Promise { + return await this.appDeployments.getAppDeploymentById(args); + } + getStatusForAppDeployment(appDeployment: AppDeploymentRecord): AppDeploymentStatus { if (appDeployment.retiredAt) { return 'retired'; @@ -220,6 +226,26 @@ export class AppDeploymentsManager { }); } + async getActiveAppDeploymentsForTarget( + target: Target, + args: { + cursor: string | null; + first: number | null; + filter: { + name?: string | null; + lastUsedBefore?: string | null; + neverUsedAndCreatedBefore?: string | null; + }; + }, + ) { + return await this.appDeployments.getActiveAppDeployments({ + targetId: target.id, + cursor: args.cursor, + first: args.first, + filter: args.filter, + }); + } + getDocumentCountForAppDeployment = batch(async args => { const appDeploymentIds = args.map(appDeployment => appDeployment.id); const counts = await this.appDeployments.getDocumentCountForAppDeployments({ diff --git a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts index c3d6709f10a..b38e208c1d0 100644 --- a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts +++ b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts @@ -53,6 +53,29 @@ export class AppDeployments { this.logger = logger.child({ source: 'AppDeployments' }); } + async getAppDeploymentById(args: { + appDeploymentId: string; + }): Promise { + this.logger.debug('get app deployment by id (appDeploymentId=%s)', args.appDeploymentId); + + const record = await this.pool.maybeOne( + sql` + SELECT + ${appDeploymentFields} + FROM + "app_deployments" + WHERE + "id" = ${args.appDeploymentId} + `, + ); + + if (!record) { + return null; + } + + return AppDeploymentModel.parse(record); + } + async findAppDeployment(args: { targetId: string; name: string; @@ -806,6 +829,314 @@ export class AppDeployments { return model.parse(result.data); } + + async getAffectedAppDeploymentsBySchemaCoordinates(args: { + targetId: string; + schemaCoordinates: string[]; + }) { + if (args.schemaCoordinates.length === 0) { + return []; + } + + this.logger.debug( + 'Finding affected app deployments by schema coordinates (targetId=%s, coordinateCount=%d)', + args.targetId, + args.schemaCoordinates.length, + ); + + let result; + try { + result = await this.clickhouse.query({ + query: cSql` + SELECT + d."app_deployment_id" AS "appDeploymentId" + , d."app_name" AS "appName" + , d."app_version" AS "appVersion" + , groupArray((doc."document_hash", doc."operation_name")) AS "operations" + , arrayDistinct(arrayFlatten(groupArray(arrayIntersect(doc."schema_coordinates", ${cSql.array(args.schemaCoordinates, 'String')})))) AS "matchingCoordinates" + FROM ( + SELECT * FROM "app_deployments" FINAL + WHERE "target_id" = ${args.targetId} AND "is_active" = true + ) AS d + INNER JOIN "app_deployment_documents" AS doc ON d."app_deployment_id" = doc."app_deployment_id" + WHERE hasAny(doc."schema_coordinates", ${cSql.array(args.schemaCoordinates, 'String')}) + GROUP BY d."app_deployment_id", d."app_name", d."app_version" + `, + queryId: 'get-affected-app-deployments-by-coordinates', + timeout: 30_000, + }); + } catch (error) { + this.logger.error( + 'Failed to query affected documents from ClickHouse (targetId=%s, coordinateCount=%d): %s', + args.targetId, + args.schemaCoordinates.length, + error instanceof Error ? error.message : String(error), + ); + throw error; + } + + const AggregatedDeploymentModel = z.object({ + appDeploymentId: z.string(), + appName: z.string(), + appVersion: z.string(), + operations: z.array(z.tuple([z.string(), z.string()])), + matchingCoordinates: z.array(z.string()), + }); + + const aggregatedDeployments = z.array(AggregatedDeploymentModel).parse(result.data); + + if (aggregatedDeployments.length === 0) { + this.logger.debug( + 'No affected operations found (targetId=%s, coordinateCount=%d)', + args.targetId, + args.schemaCoordinates.length, + ); + return []; + } + + const results = aggregatedDeployments.map(row => { + const operations = row.operations.map(([hash, operationName]) => ({ + hash, + name: operationName === '' ? null : operationName, + })); + + const operationsByCoordinate: Record< + string, + Array<{ hash: string; name: string | null }> + > = {}; + for (const coordinate of row.matchingCoordinates) { + operationsByCoordinate[coordinate] = operations; + } + + return { + appDeployment: { + id: row.appDeploymentId, + name: row.appName, + version: row.appVersion, + }, + affectedOperationsByCoordinate: operationsByCoordinate, + }; + }); + + this.logger.debug( + 'Found %d affected app deployments (targetId=%s)', + results.length, + args.targetId, + ); + + return results; + } + + async getActiveAppDeployments(args: { + targetId: string; + cursor: string | null; + first: number | null; + filter: { + name?: string | null; + lastUsedBefore?: string | null; + neverUsedAndCreatedBefore?: string | null; + }; + }) { + this.logger.debug( + 'get active app deployments (targetId=%s, cursor=%s, first=%s, filter=%o)', + args.targetId, + args.cursor ? '[provided]' : '[none]', + args.first, + args.filter, + ); + + if (args.filter.lastUsedBefore && Number.isNaN(Date.parse(args.filter.lastUsedBefore))) { + this.logger.debug( + 'invalid lastUsedBefore filter (targetId=%s, value=%s)', + args.targetId, + args.filter.lastUsedBefore, + ); + throw new Error( + `Invalid lastUsedBefore filter: "${args.filter.lastUsedBefore}" is not a valid date string`, + ); + } + if ( + args.filter.neverUsedAndCreatedBefore && + Number.isNaN(Date.parse(args.filter.neverUsedAndCreatedBefore)) + ) { + this.logger.debug( + 'invalid neverUsedAndCreatedBefore filter (targetId=%s, value=%s)', + args.targetId, + args.filter.neverUsedAndCreatedBefore, + ); + throw new Error( + `Invalid neverUsedAndCreatedBefore filter: "${args.filter.neverUsedAndCreatedBefore}" is not a valid date string`, + ); + } + + const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20; + + let cursor = null; + if (args.cursor) { + try { + cursor = decodeCreatedAtAndUUIDIdBasedCursor(args.cursor); + } catch (error) { + this.logger.error( + 'Failed to decode cursor for activeAppDeployments (targetId=%s, cursor=%s): %s', + args.targetId, + args.cursor, + error instanceof Error ? error.message : String(error), + ); + throw new Error( + `Invalid cursor format for activeAppDeployments. Expected a valid pagination cursor.`, + ); + } + } + + // Get active deployments from db + const maxDeployments = 1000; // note: hard limit + let activeDeployments; + try { + const activeDeploymentsResult = await this.pool.query(sql` + SELECT + ${appDeploymentFields} + FROM + "app_deployments" + WHERE + "target_id" = ${args.targetId} + AND "activated_at" IS NOT NULL + AND "retired_at" IS NULL + ${args.filter.name ? sql`AND "name" ILIKE ${'%' + args.filter.name + '%'}` : sql``} + ORDER BY "created_at" DESC, "id" + LIMIT ${maxDeployments} + `); + + activeDeployments = activeDeploymentsResult.rows.map(row => AppDeploymentModel.parse(row)); + } catch (error) { + this.logger.error( + 'Failed to query active deployments from PostgreSQL (targetId=%s): %s', + args.targetId, + error instanceof Error ? error.message : String(error), + ); + throw error; + } + + this.logger.debug( + 'found %d active deployments for target (targetId=%s)', + activeDeployments.length, + args.targetId, + ); + + if (activeDeployments.length === 0) { + return { + edges: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: cursor !== null, + endCursor: '', + startCursor: '', + }, + }; + } + + // Get lastUsed data from clickhouse for all active deployment IDs + const deploymentIds = activeDeployments.map(d => d.id); + let usageData; + try { + usageData = await this.getLastUsedForAppDeployments({ + appDeploymentIds: deploymentIds, + }); + } catch (error) { + this.logger.error( + 'Failed to query lastUsed data from ClickHouse (targetId=%s, deploymentCount=%d): %s', + args.targetId, + deploymentIds.length, + error instanceof Error ? error.message : String(error), + ); + throw error; + } + + // Create a map of deployment ID -> lastUsed date + const lastUsedMap = new Map(); + for (const usage of usageData) { + lastUsedMap.set(usage.appDeploymentId, usage.lastUsed); + } + + // Apply OR filter logic for date filters + // If no date filters provided, return all active deployments (name filter already applied in SQL) + const hasDateFilter = args.filter.lastUsedBefore || args.filter.neverUsedAndCreatedBefore; + + const filteredDeployments = activeDeployments.filter(deployment => { + // If no date filters, include all deployments + if (!hasDateFilter) { + return true; + } + + const lastUsed = lastUsedMap.get(deployment.id); + const hasBeenUsed = lastUsed !== undefined; + + // Check lastUsedBefore filter, deployment HAS been used AND was last used before the threshold + if (args.filter.lastUsedBefore && hasBeenUsed) { + const lastUsedDate = new Date(lastUsed); + const thresholdDate = new Date(args.filter.lastUsedBefore); + if (Number.isNaN(thresholdDate.getTime())) { + throw new Error( + `Invalid lastUsedBefore filter: "${args.filter.lastUsedBefore}" is not a valid date`, + ); + } + if (lastUsedDate < thresholdDate) { + return true; + } + } + + // Check neverUsedAndCreatedBefore filter, deployment has NEVER been used AND was created before threshold + if (args.filter.neverUsedAndCreatedBefore && !hasBeenUsed) { + const createdAtDate = new Date(deployment.createdAt); + const thresholdDate = new Date(args.filter.neverUsedAndCreatedBefore); + if (Number.isNaN(thresholdDate.getTime())) { + throw new Error( + `Invalid neverUsedAndCreatedBefore filter: "${args.filter.neverUsedAndCreatedBefore}" is not a valid date`, + ); + } + if (createdAtDate < thresholdDate) { + return true; + } + } + + return false; + }); + + this.logger.debug( + 'after filter: %d deployments match criteria (targetId=%s)', + filteredDeployments.length, + args.targetId, + ); + + // apply cursor-based pagination + let paginatedDeployments = filteredDeployments; + if (cursor) { + const cursorCreatedAt = new Date(cursor.createdAt).getTime(); + paginatedDeployments = filteredDeployments.filter(deployment => { + const deploymentCreatedAt = new Date(deployment.createdAt).getTime(); + return ( + deploymentCreatedAt < cursorCreatedAt || + (deploymentCreatedAt === cursorCreatedAt && deployment.id < cursor.id) + ); + }); + } + + // Apply limit + const hasNextPage = paginatedDeployments.length > limit; + const items = paginatedDeployments.slice(0, limit).map(node => ({ + cursor: encodeCreatedAtAndUUIDIdBasedCursor(node), + node, + })); + + return { + edges: items, + pageInfo: { + hasNextPage, + hasPreviousPage: cursor !== null, + endCursor: items[items.length - 1]?.cursor ?? '', + startCursor: items[0]?.cursor ?? '', + }, + }; + } } const appDeploymentFields = sql` diff --git a/packages/services/api/src/modules/app-deployments/resolvers/Target.ts b/packages/services/api/src/modules/app-deployments/resolvers/Target.ts index 072c2470d93..b8a5830738d 100644 --- a/packages/services/api/src/modules/app-deployments/resolvers/Target.ts +++ b/packages/services/api/src/modules/app-deployments/resolvers/Target.ts @@ -14,7 +14,7 @@ import type { TargetResolvers } from './../../../__generated__/types'; */ export const Target: Pick< TargetResolvers, - 'appDeployment' | 'appDeployments' | 'viewerCanViewAppDeployments' + 'activeAppDeployments' | 'appDeployment' | 'appDeployments' | 'viewerCanViewAppDeployments' > = { /* Implement Target resolver logic here */ appDeployment: async (target, args, { injector }) => { @@ -42,4 +42,15 @@ export const Target: Pick< } return true; }, + activeAppDeployments: async (target, args, { injector }) => { + return injector.get(AppDeploymentsManager).getActiveAppDeploymentsForTarget(target, { + cursor: args.after ?? null, + first: args.first ?? null, + filter: { + name: args.filter.name ?? null, + lastUsedBefore: args.filter.lastUsedBefore?.toISOString() ?? null, + neverUsedAndCreatedBefore: args.filter.neverUsedAndCreatedBefore?.toISOString() ?? null, + }, + }); + }, }; 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 57b855a5532..c1d96b9b814 100644 --- a/packages/services/api/src/modules/schema/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/schema/module.graphql.mappers.ts @@ -310,3 +310,13 @@ export type SchemaChangeUsageStatisticsAffectedOperationMapper = { percentage: number; targetIds: Array; }; + +export type SchemaChangeAffectedAppDeploymentMapper = { + id: string; + name: string; + version: string; + affectedOperations: Array<{ + hash: string; + name: string | null; + }>; +}; diff --git a/packages/services/api/src/modules/schema/module.graphql.ts b/packages/services/api/src/modules/schema/module.graphql.ts index 47f3a2c3df6..8d64834acad 100644 --- a/packages/services/api/src/modules/schema/module.graphql.ts +++ b/packages/services/api/src/modules/schema/module.graphql.ts @@ -485,6 +485,11 @@ export default gql` The usage statistics are only available for breaking changes and only represent a snapshot of the usage data at the time of the schema check/schema publish. """ usageStatistics: SchemaChangeUsageStatistics @tag(name: "public") + """ + List of active app deployments that would be affected by this breaking change. + Only populated for breaking changes when app deployments are enabled. + """ + affectedAppDeployments: [SchemaChangeAffectedAppDeployment!] @tag(name: "public") } type SchemaChangeUsageStatistics { @@ -548,6 +553,42 @@ export default gql` percentageFormatted: String! } + """ + An app deployment that is affected by a breaking schema change. + """ + type SchemaChangeAffectedAppDeployment { + """ + The unique identifier of the app deployment. + """ + id: ID! @tag(name: "public") + """ + The name of the app deployment. + """ + name: String! @tag(name: "public") + """ + The version of the app deployment. + """ + version: String! @tag(name: "public") + """ + The operations within this app deployment that use the affected schema coordinate. + """ + affectedOperations: [SchemaChangeAffectedAppDeploymentOperation!]! @tag(name: "public") + } + + """ + An operation within an app deployment that is affected by a breaking schema change. + """ + type SchemaChangeAffectedAppDeploymentOperation { + """ + The hash of the operation document. + """ + hash: String! @tag(name: "public") + """ + The name of the operation (if named). + """ + name: String @tag(name: "public") + } + type SchemaChangeApproval { """ User that approved this schema change. diff --git a/packages/services/api/src/modules/schema/providers/models/composite.ts b/packages/services/api/src/modules/schema/providers/models/composite.ts index e0acee26e1b..77e1ad78946 100644 --- a/packages/services/api/src/modules/schema/providers/models/composite.ts +++ b/packages/services/api/src/modules/schema/providers/models/composite.ts @@ -1,7 +1,12 @@ import { Injectable, Scope } from 'graphql-modules'; import { traceFn } from '@hive/service-common'; import { SchemaChangeType } from '@hive/storage'; -import { RegistryChecks, type ConditionalBreakingChangeDiffConfig } from '../registry-checks'; +import { AppDeployments } from '../../../app-deployments/providers/app-deployments'; +import { + GetAffectedAppDeployments, + RegistryChecks, + type ConditionalBreakingChangeDiffConfig, +} from '../registry-checks'; import { swapServices } from '../schema-helper'; import { shouldUseLatestComposableVersion } from '../schema-manager'; import type { PublishInput } from '../schema-publisher'; @@ -39,6 +44,7 @@ export class CompositeModel { constructor( private checks: RegistryChecks, private logger: Logger, + private appDeployments: AppDeployments, ) {} private async getContractChecks(args: { @@ -50,6 +56,7 @@ export class CompositeModel { compositionCheck: Awaited>; conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig; failDiffOnDangerousChange: null | boolean; + getAffectedAppDeployments: GetAffectedAppDeployments | null; }): Promise | null> { const contractResults = (args.compositionCheck.result ?? args.compositionCheck.reason) ?.contracts; @@ -78,6 +85,7 @@ export class CompositeModel { existingSdl: contract.latestValidVersion?.compositeSchemaSdl ?? null, incomingSdl: contractCompositionResult?.result?.fullSchemaSdl ?? null, failDiffOnDangerousChange: args.failDiffOnDangerousChange, + getAffectedAppDeployments: args.getAffectedAppDeployments, }), }; }), @@ -210,11 +218,18 @@ export class CompositeModel { targetId: selector.targetId, }); + const getAffectedAppDeployments: GetAffectedAppDeployments = schemaCoordinates => + this.appDeployments.getAffectedAppDeploymentsBySchemaCoordinates({ + targetId: selector.targetId, + schemaCoordinates, + }); + const contractChecks = await this.getContractChecks({ contracts, compositionCheck, conditionalBreakingChangeDiffConfig, failDiffOnDangerousChange, + getAffectedAppDeployments, }); this.logger.info('Contract checks: %o', contractChecks); @@ -228,6 +243,7 @@ export class CompositeModel { compositionCheck.result?.fullSchemaSdl ?? compositionCheck.reason?.fullSchemaSdl ?? null, conditionalBreakingChangeConfig: conditionalBreakingChangeDiffConfig, failDiffOnDangerousChange, + getAffectedAppDeployments, }), this.checks.policyCheck({ selector, @@ -476,6 +492,12 @@ export class CompositeModel { targetId: target.id, }); + const getAffectedAppDeploymentsForPublish: GetAffectedAppDeployments = schemaCoordinates => + this.appDeployments.getAffectedAppDeploymentsBySchemaCoordinates({ + targetId: target.id, + schemaCoordinates, + }); + const diffCheck = await this.checks.diff({ conditionalBreakingChangeConfig: conditionalBreakingChangeDiffConfig, includeUrlChanges: { @@ -487,6 +509,7 @@ export class CompositeModel { existingSdl: previousVersionSdl, incomingSdl: compositionCheck.result?.fullSchemaSdl ?? null, failDiffOnDangerousChange, + getAffectedAppDeployments: getAffectedAppDeploymentsForPublish, }); const contractChecks = await this.getContractChecks({ @@ -494,6 +517,7 @@ export class CompositeModel { compositionCheck, conditionalBreakingChangeDiffConfig, failDiffOnDangerousChange, + getAffectedAppDeployments: getAffectedAppDeploymentsForPublish, }); const messages: string[] = []; @@ -641,6 +665,12 @@ export class CompositeModel { targetId: selector.target, }); + const getAffectedAppDeploymentsForDelete: GetAffectedAppDeployments = schemaCoordinates => + this.appDeployments.getAffectedAppDeploymentsBySchemaCoordinates({ + targetId: selector.target, + schemaCoordinates, + }); + const diffCheck = await this.checks.diff({ conditionalBreakingChangeConfig: conditionalBreakingChangeDiffConfig, includeUrlChanges: { @@ -652,6 +682,7 @@ export class CompositeModel { existingSdl: previousVersionSdl, incomingSdl: compositionCheck.result?.fullSchemaSdl ?? null, failDiffOnDangerousChange, + getAffectedAppDeployments: getAffectedAppDeploymentsForDelete, }); const contractChecks = await this.getContractChecks({ @@ -659,6 +690,7 @@ export class CompositeModel { compositionCheck, conditionalBreakingChangeDiffConfig, failDiffOnDangerousChange, + getAffectedAppDeployments: getAffectedAppDeploymentsForDelete, }); if ( diff --git a/packages/services/api/src/modules/schema/providers/models/single.ts b/packages/services/api/src/modules/schema/providers/models/single.ts index a7f6ad6e724..f40c5dccc26 100644 --- a/packages/services/api/src/modules/schema/providers/models/single.ts +++ b/packages/services/api/src/modules/schema/providers/models/single.ts @@ -1,7 +1,12 @@ import { Injectable, Scope } from 'graphql-modules'; import { traceFn } from '@hive/service-common'; import { SchemaChangeType } from '@hive/storage'; -import { ConditionalBreakingChangeDiffConfig, RegistryChecks } from '../registry-checks'; +import { AppDeployments } from '../../../app-deployments/providers/app-deployments'; +import { + ConditionalBreakingChangeDiffConfig, + GetAffectedAppDeployments, + RegistryChecks, +} from '../registry-checks'; import type { PublishInput } from '../schema-publisher'; import type { Organization, Project, SingleSchema, Target } from './../../../../shared/entities'; import { Logger } from './../../../shared/providers/logger'; @@ -23,6 +28,7 @@ export class SingleModel { constructor( private checks: RegistryChecks, private logger: Logger, + private appDeployments: AppDeployments, ) {} @traceFn('Single modern: check', { @@ -121,6 +127,12 @@ export class SingleModel { targetId: selector.targetId, }); + const getAffectedAppDeployments: GetAffectedAppDeployments = schemaCoordinates => + this.appDeployments.getAffectedAppDeploymentsBySchemaCoordinates({ + targetId: selector.targetId, + schemaCoordinates, + }); + const [diffCheck, policyCheck] = await Promise.all([ this.checks.diff({ conditionalBreakingChangeConfig: conditionalBreakingChangeDiffConfig, @@ -130,6 +142,7 @@ export class SingleModel { existingSdl: previousVersionSdl, incomingSdl: compositionCheck.result?.fullSchemaSdl ?? null, failDiffOnDangerousChange, + getAffectedAppDeployments, }), this.checks.policyCheck({ selector, @@ -257,6 +270,12 @@ export class SingleModel { targetId: target.id, }); + const getAffectedAppDeploymentsForPublish: GetAffectedAppDeployments = schemaCoordinates => + this.appDeployments.getAffectedAppDeploymentsBySchemaCoordinates({ + targetId: target.id, + schemaCoordinates, + }); + const [metadataCheck, diffCheck] = await Promise.all([ this.checks.metadata(incoming, latestVersion ? latestVersion.schemas[0] : null), this.checks.diff({ @@ -267,6 +286,7 @@ export class SingleModel { existingSdl: previousVersionSdl, incomingSdl: compositionCheck.result?.fullSchemaSdl ?? null, failDiffOnDangerousChange, + getAffectedAppDeployments: getAffectedAppDeploymentsForPublish, }), ]); diff --git a/packages/services/api/src/modules/schema/providers/registry-checks.ts b/packages/services/api/src/modules/schema/providers/registry-checks.ts index 2e162ac55c5..3da8db3c57c 100644 --- a/packages/services/api/src/modules/schema/providers/registry-checks.ts +++ b/packages/services/api/src/modules/schema/providers/registry-checks.ts @@ -11,6 +11,7 @@ import { type RegistryServiceUrlChangeSerializableChange, type SchemaChangeType, } from '@hive/storage'; +import * as Sentry from '@sentry/node'; import { ProjectType } from '../../../shared/entities'; import { buildSortedSchemaFromSchemaObject } from '../../../shared/schema'; import { OperationsReader } from '../../operations/providers/operations-reader'; @@ -36,6 +37,19 @@ export type ConditionalBreakingChangeDiffConfig = { excludedClientNames: string[] | null; }; +export type AffectedAppDeployment = { + appDeployment: { + id: string; + name: string; + version: string; + }; + affectedOperationsByCoordinate: Record>; +}; + +export type GetAffectedAppDeployments = ( + schemaCoordinates: string[], +) => Promise; + // The reason why I'm using `result` and `reason` instead of just `data` for both: // https://bit.ly/hive-check-result-data export type CheckResult = @@ -426,6 +440,8 @@ export class RegistryChecks { /** Settings for fetching conditional breaking changes. */ conditionalBreakingChangeConfig: null | ConditionalBreakingChangeDiffConfig; failDiffOnDangerousChange: null | boolean; + /** Function to fetch affected app deployments. Called with breaking change coordinates after diff is computed. */ + getAffectedAppDeployments: GetAffectedAppDeployments | null; }) { let existingSchema: GraphQLSchema | null = null; let incomingSchema: GraphQLSchema | null = null; @@ -568,6 +584,102 @@ export class RegistryChecks { this.logger.debug('No conditional breaking change settings available'); } + // Check against active app deployments if function is provided + if (args.getAffectedAppDeployments) { + // Collect all coordinates from breaking changes and initialize affectedAppDeployments to [] + const breakingCoordinates = new Set(); + for (const change of inspectorChanges) { + if (change.criticality === CriticalityLevel.Breaking) { + // Initialize affectedAppDeployments to empty array for all breaking changes + ( + change as { + affectedAppDeployments: Array<{ + id: string; + name: string; + version: string; + affectedOperations: Array<{ hash: string; name: string | null }>; + }>; + } + ).affectedAppDeployments = []; + + const coordinate = change.breakingChangeSchemaCoordinate ?? change.path; + if (coordinate) { + breakingCoordinates.add(coordinate); + } + } + } + + if (breakingCoordinates.size > 0) { + this.logger.debug( + 'Checking affected app deployments for %d breaking schema coordinates', + breakingCoordinates.size, + ); + + try { + const affectedAppDeployments = await args.getAffectedAppDeployments( + Array.from(breakingCoordinates), + ); + + if (affectedAppDeployments.length > 0) { + this.logger.debug( + '%d app deployments affected by breaking changes', + affectedAppDeployments.length, + ); + + // Mark changes as unsafe if they affect active app deployments + for (const change of inspectorChanges) { + if (change.criticality === CriticalityLevel.Breaking) { + const coordinate = change.breakingChangeSchemaCoordinate ?? change.path; + if (coordinate) { + // Check if any deployment is affected by this specific coordinate + const deploymentsForCoordinate = affectedAppDeployments.filter( + d => d.affectedOperationsByCoordinate[coordinate]?.length > 0, + ); + + if (deploymentsForCoordinate.length > 0) { + // Override usage-based safety: change is NOT safe if app deployments are affected + change.isSafeBasedOnUsage = false; + + // Update affected app deployments for this change + ( + change as { + affectedAppDeployments: Array<{ + id: string; + name: string; + version: string; + affectedOperations: Array<{ hash: string; name: string | null }>; + }>; + } + ).affectedAppDeployments = deploymentsForCoordinate.map(d => ({ + id: d.appDeployment.id, + name: d.appDeployment.name, + version: d.appDeployment.version, + affectedOperations: d.affectedOperationsByCoordinate[coordinate], + })); + } + } + } + } + } else { + this.logger.debug('No app deployments affected by breaking changes'); + } + } catch (error) { + this.logger.error( + 'Failed to check affected app deployments (coordinateCount=%d): %s', + breakingCoordinates.size, + error instanceof Error ? error.stack : String(error), + ); + Sentry.captureException(error, { + tags: { operation: 'app-deployment-check' }, + extra: { coordinateCount: breakingCoordinates.size }, + }); + throw new Error( + `Unable to verify schema changes against app deployments. Please retry. If the issue persists, contact support.`, + ); + } + } + } + if (args.includeUrlChanges) { inspectorChanges.push( ...detectUrlChanges( diff --git a/packages/services/api/src/modules/schema/providers/schema-version-helper.ts b/packages/services/api/src/modules/schema/providers/schema-version-helper.ts index d65d6982d2e..0fe20c4b268 100644 --- a/packages/services/api/src/modules/schema/providers/schema-version-helper.ts +++ b/packages/services/api/src/modules/schema/providers/schema-version-helper.ts @@ -241,6 +241,7 @@ export class SchemaVersionHelper { filterOutFederationChanges: project.type === ProjectType.FEDERATION, conditionalBreakingChangeConfig: null, failDiffOnDangerousChange, + getAffectedAppDeployments: null, }); if (diffCheck.status === 'skipped') { diff --git a/packages/services/api/src/modules/schema/resolvers/SchemaChange.ts b/packages/services/api/src/modules/schema/resolvers/SchemaChange.ts index 26cac07f585..b5abeddc809 100644 --- a/packages/services/api/src/modules/schema/resolvers/SchemaChange.ts +++ b/packages/services/api/src/modules/schema/resolvers/SchemaChange.ts @@ -33,4 +33,18 @@ export const SchemaChange: SchemaChangeResolvers = { injector.get(BreakingSchemaChangeUsageHelper).getUsageDataForBreakingSchemaChange(change), severityLevel: change => severityMap[change.criticality], severityReason: change => change.reason, + affectedAppDeployments: change => { + if (!change.affectedAppDeployments) { + return null; + } + return change.affectedAppDeployments.map(d => ({ + id: d.id, + name: d.name, + version: d.version, + affectedOperations: d.affectedOperations.map(op => ({ + hash: op.hash, + name: op.name, + })), + })); + }, }; diff --git a/packages/services/api/src/modules/schema/resolvers/SchemaChangeAffectedAppDeployment.ts b/packages/services/api/src/modules/schema/resolvers/SchemaChangeAffectedAppDeployment.ts new file mode 100644 index 00000000000..0d667fc230f --- /dev/null +++ b/packages/services/api/src/modules/schema/resolvers/SchemaChangeAffectedAppDeployment.ts @@ -0,0 +1,14 @@ +import type { SchemaChangeAffectedAppDeploymentResolvers } from './../../../__generated__/types'; + +/* + * Note: This object type is generated because "SchemaChangeAffectedAppDeploymentMapper" is declared. This is to ensure runtime safety. + * + * When a mapper is used, it is possible to hit runtime errors in some scenarios: + * - given a field name, the schema type's field type does not match mapper's field type + * - or a schema type's field does not exist in the mapper's fields + * + * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. + */ +export const SchemaChangeAffectedAppDeployment: SchemaChangeAffectedAppDeploymentResolvers = { + /* Implement SchemaChangeAffectedAppDeployment resolver logic here */ +}; diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index a71a38fcd90..3673b37b082 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -5198,6 +5198,15 @@ export function toSerializableSchemaChange(change: SchemaChangeType): { count: number; }>; }; + affectedAppDeployments: null | Array<{ + id: string; + name: string; + version: string; + affectedOperations: Array<{ + hash: string; + name: string | null; + }>; + }>; } { return { id: change.id, @@ -5206,6 +5215,7 @@ export function toSerializableSchemaChange(change: SchemaChangeType): { isSafeBasedOnUsage: change.isSafeBasedOnUsage, approvalMetadata: change.approvalMetadata, usageStatistics: change.usageStatistics, + affectedAppDeployments: change.affectedAppDeployments, }; } diff --git a/packages/services/storage/src/schema-change-model.ts b/packages/services/storage/src/schema-change-model.ts index e9455b94bcc..fbbf93e1ee6 100644 --- a/packages/services/storage/src/schema-change-model.ts +++ b/packages/services/storage/src/schema-change-model.ts @@ -1299,6 +1299,24 @@ export const HiveSchemaChangeModel = z .nullable() .optional() .transform(value => value ?? null), + /** App deployments affected by this breaking change */ + affectedAppDeployments: z + .array( + z.object({ + id: z.string(), + name: z.string(), + version: z.string(), + affectedOperations: z.array( + z.object({ + hash: z.string(), + name: z.string().nullable(), + }), + ), + }), + ) + .nullable() + .optional() + .transform(value => value ?? null), }), ) // We inflate the schema check when reading it from the database @@ -1323,6 +1341,14 @@ export const HiveSchemaChangeModel = z topAffectedOperations: { hash: string; name: string; count: number }[]; topAffectedClients: { name: string; count: number }[]; } | null; + affectedAppDeployments: + | { + id: string; + name: string; + version: string; + affectedOperations: { hash: string; name: string | null }[]; + }[] + | null; readonly breakingChangeSchemaCoordinate: string | null; } => { const change = schemaChangeFromSerializableChange(rawChange as any); @@ -1358,6 +1384,7 @@ export const HiveSchemaChangeModel = z false, reason: change.criticality.reason ?? null, usageStatistics: rawChange.usageStatistics ?? null, + affectedAppDeployments: rawChange.affectedAppDeployments ?? null, breakingChangeSchemaCoordinate, }; }, 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 584837d12b4..80e9ee09f7f 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 { CheckIcon } from 'lucide-react'; +import { BoxIcon, CheckIcon } from 'lucide-react'; import reactStringReplace from 'react-string-replace'; import { Label, Label as LegacyLabel } from '@/components/common'; import { @@ -95,6 +95,15 @@ const ChangesBlock_SchemaChangeWithUsageFragment = graphql(` percentageFormatted } } + affectedAppDeployments { + id + name + version + affectedOperations { + hash + name + } + } } `); @@ -238,6 +247,18 @@ function ChangeItem( )} + {'affectedAppDeployments' in change && change.affectedAppDeployments?.length ? ( + + + + {change.affectedAppDeployments.length}{' '} + {change.affectedAppDeployments.length === 1 + ? 'app deployment' + : 'app deployments'}{' '} + affected + + + ) : null} {change.approval ? (
@@ -376,6 +397,133 @@ function ChangeItem( )}
+ {'affectedAppDeployments' in change && change.affectedAppDeployments?.length ? ( +
+

Affected App Deployments

+ + + Active app deployments that have operations using this schema coordinate. + + + + App Name + Version + Affected Operations + + + + {change.affectedAppDeployments.map(deployment => ( + + + + {deployment.name} + + + {deployment.version} + + + + + + +
+
Affected Operations
+
    + {deployment.affectedOperations.map(op => ( +
  • + {op.name || `[anonymous] (${op.hash.substring(0, 8)}...)`} +
  • + ))} +
+
+ +
+
+
+
+ ))} +
+
+
+ ) : null} + + ) : 'affectedAppDeployments' in change && change.affectedAppDeployments?.length ? ( +
+

Affected App Deployments

+ + + Active app deployments that have operations using this schema coordinate. + + + + App Name + Version + Affected Operations + + + + {change.affectedAppDeployments.map(deployment => ( + + + + {deployment.name} + + + {deployment.version} + + + + + + +
+
Affected Operations
+
    + {deployment.affectedOperations.map(op => ( +
  • + {op.name || `[anonymous] (${op.hash.substring(0, 8)}...)`} +
  • + ))} +
+
+ +
+
+
+
+ ))} +
+
) : change.severityLevel === SeverityLevelType.Breaking ? ( <>{change.severityReason ?? 'No details available for this breaking change.'}