From 6713f26e212353e261a5ac3fc0b15858fdd3cfad Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Fri, 5 Dec 2025 11:28:12 +0100 Subject: [PATCH 01/17] feat(api): retrieve app deployments based on last used data --- .changeset/jdpj-gvmv-utrp.md | 14 + .../tests/api/app-deployments.spec.ts | 905 ++++++++++++++++++ .../modules/app-deployments/module.graphql.ts | 42 + .../providers/app-deployments-manager.ts | 20 + .../providers/app-deployments.ts | 211 ++++ .../app-deployments/resolvers/Target.ts | 13 +- .../schema-registry/app-deployments.mdx | 82 ++ 7 files changed, 1286 insertions(+), 1 deletion(-) create mode 100644 .changeset/jdpj-gvmv-utrp.md 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/tests/api/app-deployments.spec.ts b/integration-tests/tests/api/app-deployments.spec.ts index c22946b9a81..5d360dfc33b 100644 --- a/integration-tests/tests/api/app-deployments.spec.ts +++ b/integration-tests/tests/api/app-deployments.spec.ts @@ -43,6 +43,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) { @@ -1835,3 +1866,877 @@ 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); +}); 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..74fb7bb4ad9 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 @@ -220,6 +220,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..23a31f1e921 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 @@ -806,6 +806,217 @@ export class AppDeployments { return model.parse(result.data); } + + 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/web/docs/src/content/schema-registry/app-deployments.mdx b/packages/web/docs/src/content/schema-registry/app-deployments.mdx index 91f45a5b997..e76aea71ed9 100644 --- a/packages/web/docs/src/content/schema-registry/app-deployments.mdx +++ b/packages/web/docs/src/content/schema-registry/app-deployments.mdx @@ -258,6 +258,88 @@ change to **retired**. - [`app:retire` API Reference](https://github.com/graphql-hive/console/tree/main/packages/libraries/cli#hive-appretire) +## Finding Stale App Deployments + +Hive tracks usage data for your app deployments. Each time a GraphQL request uses a persisted +document from an app deployment, Hive records when it was last used. This data helps you identify +app deployments that are candidates for retirement. + +### Usage Tracking + +When your GraphQL server or gateway reports usage to Hive, the `lastUsed` timestamp for the +corresponding app deployment is updated. You can see this information in the Hive dashboard or query +it via the GraphQL API. + +### Querying Stale Deployments via GraphQL API + +You can use the `activeAppDeployments` query to find app deployments that match specific criteria. +The date filters (`lastUsedBefore`, `neverUsedAndCreatedBefore`) use OR semantics. deployments +matching **either** date condition are returned. The `name` filter uses AND semantics to narrow down +results. + +```graphql +query FindStaleDeployments($target: TargetReferenceInput!) { + target(reference: $target) { + activeAppDeployments( + filter: { + # Optional: filter by app name (case-insensitive partial match) + name: "my-app" + # Deployments last used more than 30 days ago + lastUsedBefore: "2024-11-01T00:00:00Z" + # OR deployments that have never been used and are older than 30 days + neverUsedAndCreatedBefore: "2024-11-01T00:00:00Z" + } + ) { + edges { + node { + name + version + createdAt + lastUsed + } + } + } + } +} +``` + +| Filter Parameter | Description | +| --------------------------- | ------------------------------------------------------------------------------------------------ | +| `name` | Filter by app deployment name (case-insensitive partial match). Uses AND semantics. | +| `lastUsedBefore` | Return deployments that were last used before this timestamp. Uses OR with other date filter. | +| `neverUsedAndCreatedBefore` | Return deployments never used and created before this timestamp. Uses OR with other date filter. | + +### Retirement Workflow + +A typical workflow for retiring stale deployments: + +1. **Query stale deployments** using the `activeAppDeployments` query with appropriate filters +2. **Review the results** to ensure you're not retiring deployments still in use +3. **Retire deployments** using the `app:retire` CLI command or GraphQL mutation + +### Automated Cleanup + +For teams with many app deployments (e.g., one per PR or branch), you can automate cleanup by +combining the GraphQL API with the Hive CLI. + +Example script pattern: + +```bash +# Query stale deployments via GraphQL API +# Parse the response to get app names and versions +# Retire each deployment using the CLI: +hive app:retire \ + --registry.accessToken "" \ + --target "//" \ + --name "" \ + --version "" +``` + + + Always review deployments before retiring them programmatically. Consider protecting your latest + production deployment to avoid accidentally retiring active versions. + + ## Persisted Documents on GraphQL Server and Gateway Persisted documents can be used on your GraphQL server or Gateway to reduce the payload size of your From 225ce62f3d82189064e11f1502d7568d96b3378c Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 8 Dec 2025 14:06:46 +0100 Subject: [PATCH 02/17] feat(api): conditional breaking changes based on app deployments --- .../tests/api/app-deployments.spec.ts | 217 ++++++++++++++++++ .../alerts/providers/adapters/msteams.spec.ts | 4 + .../providers/app-deployments-manager.ts | 4 + .../providers/app-deployments.ts | 130 +++++++++++ .../modules/schema/module.graphql.mappers.ts | 10 + .../api/src/modules/schema/module.graphql.ts | 41 ++++ .../schema/providers/schema-publisher.ts | 85 +++++++ .../modules/schema/resolvers/SchemaChange.ts | 14 ++ .../SchemaChangeAffectedAppDeployment.ts | 14 ++ packages/services/storage/src/index.ts | 10 + .../storage/src/schema-change-model.ts | 25 ++ 11 files changed, 554 insertions(+) create mode 100644 packages/services/api/src/modules/schema/resolvers/SchemaChangeAffectedAppDeployment.ts diff --git a/integration-tests/tests/api/app-deployments.spec.ts b/integration-tests/tests/api/app-deployments.spec.ts index 5d360dfc33b..7a693af33e1 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'; @@ -2740,3 +2741,219 @@ test('activeAppDeployments check pagination clamp', async () => { 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 + 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'); +}); 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/providers/app-deployments-manager.ts b/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts index 74fb7bb4ad9..84d0c6f33b9 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,10 @@ 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'; 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 23a31f1e921..2f3e42408ad 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; @@ -807,6 +830,113 @@ 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, + ); + + 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 + `); + + const activeDeployments = activeDeploymentsResult.rows.map(row => + AppDeploymentModel.parse(row), + ); + + if (activeDeployments.length === 0) { + this.logger.debug('No active app deployments found (targetId=%s)', args.targetId); + return []; + } + + const deploymentIds = activeDeployments.map(d => d.id); + + const affectedDocumentsResult = await this.clickhouse.query({ + query: cSql` + SELECT + "app_deployment_id" AS "appDeploymentId" + , "document_hash" AS "hash" + , "operation_name" AS "operationName" + FROM + "app_deployment_documents" + WHERE + "app_deployment_id" IN (${cSql.array(deploymentIds, 'String')}) + AND hasAny("schema_coordinates", ${cSql.array(args.schemaCoordinates, 'String')}) + ORDER BY "app_deployment_id", "document_hash" + LIMIT 1 BY "app_deployment_id", "document_hash" + `, + queryId: 'get-affected-app-deployments-by-coordinates', + timeout: 30_000, + }); + + const AffectedDocumentModel = z.object({ + appDeploymentId: z.string(), + hash: z.string(), + operationName: z.string().transform(value => (value === '' ? null : value)), + }); + + const affectedDocuments = z.array(AffectedDocumentModel).parse(affectedDocumentsResult.data); + + if (affectedDocuments.length === 0) { + this.logger.debug( + 'No affected operations found (targetId=%s, coordinateCount=%d)', + args.targetId, + args.schemaCoordinates.length, + ); + return []; + } + + const deploymentIdToDeployment = new Map(activeDeployments.map(d => [d.id, d])); + const deploymentIdToOperations = new Map< + string, + Array<{ hash: string; name: string | null }> + >(); + + for (const doc of affectedDocuments) { + const ops = deploymentIdToOperations.get(doc.appDeploymentId) ?? []; + ops.push({ + hash: doc.hash, + name: doc.operationName, + }); + deploymentIdToOperations.set(doc.appDeploymentId, ops); + } + + const result = []; + for (const [deploymentId, operations] of deploymentIdToOperations) { + const deployment = deploymentIdToDeployment.get(deploymentId); + if (deployment) { + result.push({ + appDeployment: deployment, + affectedOperations: operations, + }); + } + } + + this.logger.debug( + 'Found %d affected app deployments with %d total operations (targetId=%s)', + result.length, + affectedDocuments.length, + args.targetId, + ); + + return result; + } + async getActiveAppDeployments(args: { targetId: string; cursor: string | 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/schema-publisher.ts b/packages/services/api/src/modules/schema/providers/schema-publisher.ts index 3e4f7d45b1c..eda671cbf18 100644 --- a/packages/services/api/src/modules/schema/providers/schema-publisher.ts +++ b/packages/services/api/src/modules/schema/providers/schema-publisher.ts @@ -20,6 +20,7 @@ import { createPeriod } from '../../../shared/helpers'; import { isGitHubRepositoryString } from '../../../shared/is-github-repository-string'; import { bolderize } from '../../../shared/markdown'; import { AlertsManager } from '../../alerts/providers/alerts-manager'; +import { AppDeployments } from '../../app-deployments/providers/app-deployments'; import { Session } from '../../auth/lib/authz'; import { RateLimitProvider } from '../../commerce/providers/rate-limit.provider'; import { @@ -151,6 +152,7 @@ export class SchemaPublisher { private schemaVersionHelper: SchemaVersionHelper, private operationsReader: OperationsReader, private idTranslator: IdTranslator, + private appDeployments: AppDeployments, @Inject(SCHEMA_MODULE_CONFIG) private schemaModuleConfig: SchemaModuleConfig, singleModel: SingleModel, compositeModel: CompositeModel, @@ -280,6 +282,77 @@ export class SchemaPublisher { }; } + private async enrichBreakingChangesWithAffectedAppDeployments(args: { + targetId: string; + breakingChanges: SchemaChangeType[] | null; + }): Promise { + if (!args.breakingChanges?.length) { + return; + } + + const schemaCoordinates = new Set(); + for (const change of args.breakingChanges) { + const coordinate = change.breakingChangeSchemaCoordinate ?? change.path; + if (coordinate) { + schemaCoordinates.add(coordinate); + } + } + + if (schemaCoordinates.size === 0) { + return; + } + + this.logger.debug( + 'Checking affected app deployments for %d schema coordinates', + schemaCoordinates.size, + ); + + // Query for affected app deployments + const affectedDeployments = + await this.appDeployments.getAffectedAppDeploymentsBySchemaCoordinates({ + targetId: args.targetId, + schemaCoordinates: Array.from(schemaCoordinates), + }); + + if (affectedDeployments.length === 0) { + this.logger.debug('No app deployments affected by breaking changes'); + return; + } + + this.logger.debug( + '%d app deployments affected by breaking changes', + affectedDeployments.length, + ); + + // Create a map from schema coordinate to affected deployments + // Note: Each deployment may have operations using multiple coordinates + // We need to check each operation's coordinates to match with breaking changes + // For simplicity, we'll query all coordinates and map them + + // Group affected deployments by which schema coordinates they use + // For now, we'll attach all affected deployments to all breaking changes + // since the operation-level filtering is already done in the query + const affectedAppDeploymentsData = affectedDeployments.map(d => ({ + id: d.appDeployment.id, + name: d.appDeployment.name, + version: d.appDeployment.version, + affectedOperations: d.affectedOperations, + })); + + // Attach affected app deployments to each breaking change + for (const change of args.breakingChanges) { + const coordinate = change.breakingChangeSchemaCoordinate ?? change.path; + if (coordinate) { + // Filter to only include deployments that have operations using this specific coordinate + // For efficiency, we already queried with all coordinates, so all returned deployments + // are affected by at least one of the breaking changes + ( + change as { affectedAppDeployments: typeof affectedAppDeploymentsData } + ).affectedAppDeployments = affectedAppDeploymentsData; + } + } + } + @traceFn('SchemaPublisher.internalCheck', { initAttributes: input => ({ 'hive.organization.slug': input.target?.bySelector?.organizationSlug, @@ -655,6 +728,18 @@ export class SchemaPublisher { const retention = await this.rateLimit.getRetention({ targetId: target.id }); const expiresAt = retention ? new Date(Date.now() + retention * millisecondsPerDay) : null; + // enrich breaking changes with affected app deployments + if ( + checkResult.conclusion === SchemaCheckConclusion.Failure || + checkResult.conclusion === SchemaCheckConclusion.Success + ) { + const breakingChanges = checkResult.state?.schemaChanges?.breaking ?? null; + await this.enrichBreakingChangesWithAffectedAppDeployments({ + targetId: target.id, + breakingChanges, + }); + } + if (checkResult.conclusion === SchemaCheckConclusion.Failure) { schemaCheck = await this.storage.createSchemaCheck({ schemaSDL: sdl, diff --git a/packages/services/api/src/modules/schema/resolvers/SchemaChange.ts b/packages/services/api/src/modules/schema/resolvers/SchemaChange.ts index 26cac07f585..c7eadb8c261 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?.length) { + 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..049858c8b70 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,12 @@ 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 +1382,7 @@ export const HiveSchemaChangeModel = z false, reason: change.criticality.reason ?? null, usageStatistics: rawChange.usageStatistics ?? null, + affectedAppDeployments: rawChange.affectedAppDeployments ?? null, breakingChangeSchemaCoordinate, }; }, From fa81d6a6faeb2279151cd52b1ebedd9bf40150f6 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 8 Dec 2025 14:12:37 +0100 Subject: [PATCH 03/17] add changeset --- .changeset/flower-ball-gulp.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/flower-ball-gulp.md 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. From da40cd7dbae18fbde18c845957674cc7b35eb127 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 8 Dec 2025 14:33:46 +0100 Subject: [PATCH 04/17] map specific breaking change coord to appdeployment --- .../tests/api/app-deployments.spec.ts | 222 ++++++++++++++++++ .../providers/app-deployments.ts | 109 ++++++--- .../schema/providers/schema-publisher.ts | 82 ++++--- 3 files changed, 337 insertions(+), 76 deletions(-) diff --git a/integration-tests/tests/api/app-deployments.spec.ts b/integration-tests/tests/api/app-deployments.spec.ts index 7a693af33e1..9982e763eae 100644 --- a/integration-tests/tests/api/app-deployments.spec.ts +++ b/integration-tests/tests/api/app-deployments.spec.ts @@ -2957,3 +2957,225 @@ test('schema check shows affected app deployments for breaking changes', async ( 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', + ); +}); 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 2f3e42408ad..2ece6f4392d 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 @@ -844,16 +844,27 @@ export class AppDeployments { args.schemaCoordinates.length, ); - 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 - `); + let activeDeploymentsResult; + try { + 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 + LIMIT 1000 + `); + } catch (error) { + this.logger.error( + 'Failed to query active app deployments from PostgreSQL (targetId=%s): %s', + args.targetId, + error instanceof Error ? error.message : String(error), + ); + throw error; + } const activeDeployments = activeDeploymentsResult.rows.map(row => AppDeploymentModel.parse(row), @@ -866,28 +877,43 @@ export class AppDeployments { const deploymentIds = activeDeployments.map(d => d.id); - const affectedDocumentsResult = await this.clickhouse.query({ - query: cSql` - SELECT - "app_deployment_id" AS "appDeploymentId" - , "document_hash" AS "hash" - , "operation_name" AS "operationName" - FROM - "app_deployment_documents" - WHERE - "app_deployment_id" IN (${cSql.array(deploymentIds, 'String')}) - AND hasAny("schema_coordinates", ${cSql.array(args.schemaCoordinates, 'String')}) - ORDER BY "app_deployment_id", "document_hash" - LIMIT 1 BY "app_deployment_id", "document_hash" - `, - queryId: 'get-affected-app-deployments-by-coordinates', - timeout: 30_000, - }); + let affectedDocumentsResult; + try { + affectedDocumentsResult = await this.clickhouse.query({ + query: cSql` + SELECT + "app_deployment_id" AS "appDeploymentId" + , "document_hash" AS "hash" + , "operation_name" AS "operationName" + , arrayIntersect("schema_coordinates", ${cSql.array(args.schemaCoordinates, 'String')}) AS "matchingCoordinates" + FROM + "app_deployment_documents" + WHERE + "app_deployment_id" IN (${cSql.array(deploymentIds, 'String')}) + AND hasAny("schema_coordinates", ${cSql.array(args.schemaCoordinates, 'String')}) + ORDER BY "app_deployment_id", "document_hash" + LIMIT 1 BY "app_deployment_id", "document_hash" + LIMIT 10000 + `, + queryId: 'get-affected-app-deployments-by-coordinates', + timeout: 30_000, + }); + } catch (error) { + this.logger.error( + 'Failed to query affected documents from ClickHouse (targetId=%s, deploymentCount=%d, coordinateCount=%d): %s', + args.targetId, + deploymentIds.length, + args.schemaCoordinates.length, + error instanceof Error ? error.message : String(error), + ); + throw error; + } const AffectedDocumentModel = z.object({ appDeploymentId: z.string(), hash: z.string(), operationName: z.string().transform(value => (value === '' ? null : value)), + matchingCoordinates: z.array(z.string()), }); const affectedDocuments = z.array(AffectedDocumentModel).parse(affectedDocumentsResult.data); @@ -902,27 +928,36 @@ export class AppDeployments { } const deploymentIdToDeployment = new Map(activeDeployments.map(d => [d.id, d])); - const deploymentIdToOperations = new Map< + // Map: deploymentId -> coordinate -> operations + const deploymentCoordinateOperations = new Map< string, - Array<{ hash: string; name: string | null }> + Map> >(); for (const doc of affectedDocuments) { - const ops = deploymentIdToOperations.get(doc.appDeploymentId) ?? []; - ops.push({ - hash: doc.hash, - name: doc.operationName, - }); - deploymentIdToOperations.set(doc.appDeploymentId, ops); + let coordinateMap = deploymentCoordinateOperations.get(doc.appDeploymentId); + if (!coordinateMap) { + coordinateMap = new Map(); + deploymentCoordinateOperations.set(doc.appDeploymentId, coordinateMap); + } + + for (const coordinate of doc.matchingCoordinates) { + const ops = coordinateMap.get(coordinate) ?? []; + ops.push({ + hash: doc.hash, + name: doc.operationName, + }); + coordinateMap.set(coordinate, ops); + } } const result = []; - for (const [deploymentId, operations] of deploymentIdToOperations) { + for (const [deploymentId, coordinateMap] of deploymentCoordinateOperations) { const deployment = deploymentIdToDeployment.get(deploymentId); if (deployment) { result.push({ appDeployment: deployment, - affectedOperations: operations, + affectedOperationsByCoordinate: Object.fromEntries(coordinateMap), }); } } diff --git a/packages/services/api/src/modules/schema/providers/schema-publisher.ts b/packages/services/api/src/modules/schema/providers/schema-publisher.ts index eda671cbf18..05dda8f0506 100644 --- a/packages/services/api/src/modules/schema/providers/schema-publisher.ts +++ b/packages/services/api/src/modules/schema/providers/schema-publisher.ts @@ -307,49 +307,53 @@ export class SchemaPublisher { schemaCoordinates.size, ); - // Query for affected app deployments - const affectedDeployments = - await this.appDeployments.getAffectedAppDeploymentsBySchemaCoordinates({ - targetId: args.targetId, - schemaCoordinates: Array.from(schemaCoordinates), - }); + try { + // Query for affected app deployments + const affectedDeployments = + await this.appDeployments.getAffectedAppDeploymentsBySchemaCoordinates({ + targetId: args.targetId, + schemaCoordinates: Array.from(schemaCoordinates), + }); - if (affectedDeployments.length === 0) { - this.logger.debug('No app deployments affected by breaking changes'); - return; - } + if (affectedDeployments.length === 0) { + this.logger.debug('No app deployments affected by breaking changes'); + return; + } - this.logger.debug( - '%d app deployments affected by breaking changes', - affectedDeployments.length, - ); + this.logger.debug( + '%d app deployments affected by breaking changes', + affectedDeployments.length, + ); - // Create a map from schema coordinate to affected deployments - // Note: Each deployment may have operations using multiple coordinates - // We need to check each operation's coordinates to match with breaking changes - // For simplicity, we'll query all coordinates and map them - - // Group affected deployments by which schema coordinates they use - // For now, we'll attach all affected deployments to all breaking changes - // since the operation-level filtering is already done in the query - const affectedAppDeploymentsData = affectedDeployments.map(d => ({ - id: d.appDeployment.id, - name: d.appDeployment.name, - version: d.appDeployment.version, - affectedOperations: d.affectedOperations, - })); - - // Attach affected app deployments to each breaking change - for (const change of args.breakingChanges) { - const coordinate = change.breakingChangeSchemaCoordinate ?? change.path; - if (coordinate) { - // Filter to only include deployments that have operations using this specific coordinate - // For efficiency, we already queried with all coordinates, so all returned deployments - // are affected by at least one of the breaking changes - ( - change as { affectedAppDeployments: typeof affectedAppDeploymentsData } - ).affectedAppDeployments = affectedAppDeploymentsData; + // Attach affected app deployments to each breaking change + // Only include deployments that have operations using that specific coordinate + for (const change of args.breakingChanges) { + const coordinate = change.breakingChangeSchemaCoordinate ?? change.path; + if (coordinate) { + // Filter to only include deployments that have operations for this specific coordinate + const deploymentsForCoordinate = affectedDeployments + .filter(d => d.affectedOperationsByCoordinate[coordinate]?.length > 0) + .map(d => ({ + id: d.appDeployment.id, + name: d.appDeployment.name, + version: d.appDeployment.version, + affectedOperations: d.affectedOperationsByCoordinate[coordinate], + })); + + if (deploymentsForCoordinate.length > 0) { + ( + change as { affectedAppDeployments: typeof deploymentsForCoordinate } + ).affectedAppDeployments = deploymentsForCoordinate; + } + } } + } catch (error) { + this.logger.error( + 'Failed to fetch affected app deployments for breaking changes (targetId=%s, coordinateCount=%d): %s', + args.targetId, + schemaCoordinates.size, + error instanceof Error ? error.message : String(error), + ); } } From 6360c350c017c0d2b076a6a29def50eced29c8bc Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 8 Dec 2025 14:41:30 +0100 Subject: [PATCH 05/17] display affected appdeployments on schema changes --- .../target/history/errors-and-changes.tsx | 150 +++++++++++++++++- 1 file changed, 149 insertions(+), 1 deletion(-) 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.'} From 309e6b127754762c83a68e87148d76b3ba403522 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 8 Dec 2025 14:43:03 +0100 Subject: [PATCH 06/17] add docs --- .../schema-registry/app-deployments.mdx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/web/docs/src/content/schema-registry/app-deployments.mdx b/packages/web/docs/src/content/schema-registry/app-deployments.mdx index e76aea71ed9..127613ee327 100644 --- a/packages/web/docs/src/content/schema-registry/app-deployments.mdx +++ b/packages/web/docs/src/content/schema-registry/app-deployments.mdx @@ -258,6 +258,49 @@ change to **retired**. - [`app:retire` API Reference](https://github.com/graphql-hive/console/tree/main/packages/libraries/cli#hive-appretire) +## Schema Checks and Affected App Deployments + +When you run a schema check that detects breaking changes, Hive automatically identifies which active +app deployments would be affected by those changes. This helps you understand the real-world impact +of schema changes before deploying them. + +### How It Works + +During a schema check, Hive analyzes the breaking changes and matches them against the persisted +documents in your active app deployments. Hive identifies all app deployments that have operations +using any of the affected schema coordinates (e.g., fields like `Query.hello` that are being removed). + +For each affected app deployment, you'll see: + +- **The deployment** name and version +- **Which specific operations** within that deployment use the affected schema coordinates + +This information is displayed alongside the breaking changes in the schema check results, helping +you understand the collective impact across all your active app deployments. + +### Example + +If you have a breaking change that removes the `Query.users` field, and you have an active app +deployment `mobile-app@2.1.0` with operations that query `Query.users`, the schema check will show: + +- The breaking change: "Field 'users' was removed from object type 'Query'" +- Affected app deployment: `mobile-app` version `2.1.0` +- Affected operations: The specific operation names and hashes that use this field + +### Benefits + +- **Impact assessment**: Understand which client applications would break before deploying schema + changes +- **Coordination**: Know which teams need to update their apps before a breaking change can be + safely deployed +- **Risk mitigation**: Make informed decisions about whether to proceed with breaking changes or + find alternative approaches + + + Only **active** app deployments (published and not retired) are checked for affected operations. + Pending and retired deployments are not included in this analysis. + + ## Finding Stale App Deployments Hive tracks usage data for your app deployments. Each time a GraphQL request uses a persisted From 577e5acfc481cf514f77082dc5ce8a7a22347148 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 8 Dec 2025 15:01:36 +0100 Subject: [PATCH 07/17] add more integration tests --- .../tests/api/app-deployments.spec.ts | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) diff --git a/integration-tests/tests/api/app-deployments.spec.ts b/integration-tests/tests/api/app-deployments.spec.ts index 9982e763eae..6dcbe0a92cc 100644 --- a/integration-tests/tests/api/app-deployments.spec.ts +++ b/integration-tests/tests/api/app-deployments.spec.ts @@ -3179,3 +3179,288 @@ test('breaking changes show only deployments affected by their specific coordina '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).toBeNull(); +}); + +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).toBeNull(); +}); From 5d43005ada66929740af2b793bfb073ef07235f6 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 8 Dec 2025 18:11:42 +0100 Subject: [PATCH 08/17] add more integration tests --- .../tests/api/app-deployments.spec.ts | 418 ++++++++++++++++++ 1 file changed, 418 insertions(+) diff --git a/integration-tests/tests/api/app-deployments.spec.ts b/integration-tests/tests/api/app-deployments.spec.ts index 6dcbe0a92cc..4af43a460b1 100644 --- a/integration-tests/tests/api/app-deployments.spec.ts +++ b/integration-tests/tests/api/app-deployments.spec.ts @@ -3464,3 +3464,421 @@ test('pending (non-activated) app deployments are excluded from affected deploym expect(helloRemoval).toBeDefined(); expect(helloRemoval?.node.affectedAppDeployments).toBeNull(); }); + +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'); +}); From 5638cd21bd621cad28b54a9d66f6d66484730a08 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Wed, 10 Dec 2025 13:02:54 +0100 Subject: [PATCH 09/17] prettier fixes --- .../tests/api/app-deployments.spec.ts | 8 ++------ .../providers/app-deployments-manager.ts | 4 +++- .../services/storage/src/schema-change-model.ts | 14 ++++++++------ .../content/schema-registry/app-deployments.mdx | 9 +++++---- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/integration-tests/tests/api/app-deployments.spec.ts b/integration-tests/tests/api/app-deployments.spec.ts index 4af43a460b1..0cf136a8a3e 100644 --- a/integration-tests/tests/api/app-deployments.spec.ts +++ b/integration-tests/tests/api/app-deployments.spec.ts @@ -3613,9 +3613,7 @@ test('multiple deployments affected by same breaking change all appear', async ( // Both deployments should appear expect(helloRemoval?.node.affectedAppDeployments?.length).toBe(2); - const appNames = helloRemoval?.node.affectedAppDeployments?.map( - (d: { name: string }) => d.name, - ); + const appNames = helloRemoval?.node.affectedAppDeployments?.map((d: { name: string }) => d.name); expect(appNames).toContain('multi-app-1'); expect(appNames).toContain('multi-app-2'); }); @@ -3860,9 +3858,7 @@ test('multiple operations in same deployment affected by same change', async () edge.node.message.includes('hello'), ); // Wait until all 3 operations appear - return ( - (helloRemoval?.node.affectedAppDeployments?.[0]?.affectedOperations?.length ?? 0) >= 3 - ); + return (helloRemoval?.node.affectedAppDeployments?.[0]?.affectedOperations?.length ?? 0) >= 3; }, { maxWait: 15_000 }, ); 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 84d0c6f33b9..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,7 +43,9 @@ export class AppDeploymentsManager { return appDeployment; } - async getAppDeploymentById(args: { appDeploymentId: string }): Promise { + async getAppDeploymentById(args: { + appDeploymentId: string; + }): Promise { return await this.appDeployments.getAppDeploymentById(args); } diff --git a/packages/services/storage/src/schema-change-model.ts b/packages/services/storage/src/schema-change-model.ts index 049858c8b70..fbbf93e1ee6 100644 --- a/packages/services/storage/src/schema-change-model.ts +++ b/packages/services/storage/src/schema-change-model.ts @@ -1341,12 +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; + affectedAppDeployments: + | { + id: string; + name: string; + version: string; + affectedOperations: { hash: string; name: string | null }[]; + }[] + | null; readonly breakingChangeSchemaCoordinate: string | null; } => { const change = schemaChangeFromSerializableChange(rawChange as any); diff --git a/packages/web/docs/src/content/schema-registry/app-deployments.mdx b/packages/web/docs/src/content/schema-registry/app-deployments.mdx index 127613ee327..502ccdd0e9b 100644 --- a/packages/web/docs/src/content/schema-registry/app-deployments.mdx +++ b/packages/web/docs/src/content/schema-registry/app-deployments.mdx @@ -260,15 +260,16 @@ change to **retired**. ## Schema Checks and Affected App Deployments -When you run a schema check that detects breaking changes, Hive automatically identifies which active -app deployments would be affected by those changes. This helps you understand the real-world impact -of schema changes before deploying them. +When you run a schema check that detects breaking changes, Hive automatically identifies which +active app deployments would be affected by those changes. This helps you understand the real-world +impact of schema changes before deploying them. ### How It Works During a schema check, Hive analyzes the breaking changes and matches them against the persisted documents in your active app deployments. Hive identifies all app deployments that have operations -using any of the affected schema coordinates (e.g., fields like `Query.hello` that are being removed). +using any of the affected schema coordinates (e.g., fields like `Query.hello` that are being +removed). For each affected app deployment, you'll see: From ddbd2d71a3f6f48b62d12bed8894b7e7f683aa9e Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Wed, 10 Dec 2025 13:06:03 +0100 Subject: [PATCH 10/17] remove docs --- .../schema-registry/app-deployments.mdx | 126 ------------------ 1 file changed, 126 deletions(-) diff --git a/packages/web/docs/src/content/schema-registry/app-deployments.mdx b/packages/web/docs/src/content/schema-registry/app-deployments.mdx index 502ccdd0e9b..91f45a5b997 100644 --- a/packages/web/docs/src/content/schema-registry/app-deployments.mdx +++ b/packages/web/docs/src/content/schema-registry/app-deployments.mdx @@ -258,132 +258,6 @@ change to **retired**. - [`app:retire` API Reference](https://github.com/graphql-hive/console/tree/main/packages/libraries/cli#hive-appretire) -## Schema Checks and Affected App Deployments - -When you run a schema check that detects breaking changes, Hive automatically identifies which -active app deployments would be affected by those changes. This helps you understand the real-world -impact of schema changes before deploying them. - -### How It Works - -During a schema check, Hive analyzes the breaking changes and matches them against the persisted -documents in your active app deployments. Hive identifies all app deployments that have operations -using any of the affected schema coordinates (e.g., fields like `Query.hello` that are being -removed). - -For each affected app deployment, you'll see: - -- **The deployment** name and version -- **Which specific operations** within that deployment use the affected schema coordinates - -This information is displayed alongside the breaking changes in the schema check results, helping -you understand the collective impact across all your active app deployments. - -### Example - -If you have a breaking change that removes the `Query.users` field, and you have an active app -deployment `mobile-app@2.1.0` with operations that query `Query.users`, the schema check will show: - -- The breaking change: "Field 'users' was removed from object type 'Query'" -- Affected app deployment: `mobile-app` version `2.1.0` -- Affected operations: The specific operation names and hashes that use this field - -### Benefits - -- **Impact assessment**: Understand which client applications would break before deploying schema - changes -- **Coordination**: Know which teams need to update their apps before a breaking change can be - safely deployed -- **Risk mitigation**: Make informed decisions about whether to proceed with breaking changes or - find alternative approaches - - - Only **active** app deployments (published and not retired) are checked for affected operations. - Pending and retired deployments are not included in this analysis. - - -## Finding Stale App Deployments - -Hive tracks usage data for your app deployments. Each time a GraphQL request uses a persisted -document from an app deployment, Hive records when it was last used. This data helps you identify -app deployments that are candidates for retirement. - -### Usage Tracking - -When your GraphQL server or gateway reports usage to Hive, the `lastUsed` timestamp for the -corresponding app deployment is updated. You can see this information in the Hive dashboard or query -it via the GraphQL API. - -### Querying Stale Deployments via GraphQL API - -You can use the `activeAppDeployments` query to find app deployments that match specific criteria. -The date filters (`lastUsedBefore`, `neverUsedAndCreatedBefore`) use OR semantics. deployments -matching **either** date condition are returned. The `name` filter uses AND semantics to narrow down -results. - -```graphql -query FindStaleDeployments($target: TargetReferenceInput!) { - target(reference: $target) { - activeAppDeployments( - filter: { - # Optional: filter by app name (case-insensitive partial match) - name: "my-app" - # Deployments last used more than 30 days ago - lastUsedBefore: "2024-11-01T00:00:00Z" - # OR deployments that have never been used and are older than 30 days - neverUsedAndCreatedBefore: "2024-11-01T00:00:00Z" - } - ) { - edges { - node { - name - version - createdAt - lastUsed - } - } - } - } -} -``` - -| Filter Parameter | Description | -| --------------------------- | ------------------------------------------------------------------------------------------------ | -| `name` | Filter by app deployment name (case-insensitive partial match). Uses AND semantics. | -| `lastUsedBefore` | Return deployments that were last used before this timestamp. Uses OR with other date filter. | -| `neverUsedAndCreatedBefore` | Return deployments never used and created before this timestamp. Uses OR with other date filter. | - -### Retirement Workflow - -A typical workflow for retiring stale deployments: - -1. **Query stale deployments** using the `activeAppDeployments` query with appropriate filters -2. **Review the results** to ensure you're not retiring deployments still in use -3. **Retire deployments** using the `app:retire` CLI command or GraphQL mutation - -### Automated Cleanup - -For teams with many app deployments (e.g., one per PR or branch), you can automate cleanup by -combining the GraphQL API with the Hive CLI. - -Example script pattern: - -```bash -# Query stale deployments via GraphQL API -# Parse the response to get app names and versions -# Retire each deployment using the CLI: -hive app:retire \ - --registry.accessToken "" \ - --target "//" \ - --name "" \ - --version "" -``` - - - Always review deployments before retiring them programmatically. Consider protecting your latest - production deployment to avoid accidentally retiring active versions. - - ## Persisted Documents on GraphQL Server and Gateway Persisted documents can be used on your GraphQL server or Gateway to reduce the payload size of your From 3db42412f9d987bb83c477d4f0672063ae26cf81 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Thu, 11 Dec 2025 16:42:18 +0100 Subject: [PATCH 11/17] schema check fails if breaking change affects app deployment even when usage data passes --- .../schema/providers/models/composite.ts | 34 +++++- .../modules/schema/providers/models/single.ts | 18 ++- .../schema/providers/registry-checks.ts | 112 ++++++++++++++++++ .../schema/providers/schema-publisher.ts | 89 -------------- .../schema/providers/schema-version-helper.ts | 1 + .../modules/schema/resolvers/SchemaChange.ts | 2 +- 6 files changed, 164 insertions(+), 92 deletions(-) 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..5a9338a44a3 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,8 @@ 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 +24,7 @@ export class SingleModel { constructor( private checks: RegistryChecks, private logger: Logger, + private appDeployments: AppDeployments, ) {} @traceFn('Single modern: check', { @@ -121,6 +123,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 +138,7 @@ export class SingleModel { existingSdl: previousVersionSdl, incomingSdl: compositionCheck.result?.fullSchemaSdl ?? null, failDiffOnDangerousChange, + getAffectedAppDeployments, }), this.checks.policyCheck({ selector, @@ -257,6 +266,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 +282,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..14710cb02bb 100644 --- a/packages/services/api/src/modules/schema/providers/registry-checks.ts +++ b/packages/services/api/src/modules/schema/providers/registry-checks.ts @@ -2,6 +2,7 @@ import { URL } from 'node:url'; import { type GraphQLSchema } from 'graphql'; import { Injectable, Scope } from 'graphql-modules'; import hashObject from 'object-hash'; +import * as Sentry from '@sentry/node'; import { ChangeType, CriticalityLevel, TypeOfChangeType } from '@graphql-inspector/core'; import type { CheckPolicyResponse } from '@hive/policy'; import type { CompositionFailureError, ContractsInputType } from '@hive/schema'; @@ -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-publisher.ts b/packages/services/api/src/modules/schema/providers/schema-publisher.ts index 05dda8f0506..3e4f7d45b1c 100644 --- a/packages/services/api/src/modules/schema/providers/schema-publisher.ts +++ b/packages/services/api/src/modules/schema/providers/schema-publisher.ts @@ -20,7 +20,6 @@ import { createPeriod } from '../../../shared/helpers'; import { isGitHubRepositoryString } from '../../../shared/is-github-repository-string'; import { bolderize } from '../../../shared/markdown'; import { AlertsManager } from '../../alerts/providers/alerts-manager'; -import { AppDeployments } from '../../app-deployments/providers/app-deployments'; import { Session } from '../../auth/lib/authz'; import { RateLimitProvider } from '../../commerce/providers/rate-limit.provider'; import { @@ -152,7 +151,6 @@ export class SchemaPublisher { private schemaVersionHelper: SchemaVersionHelper, private operationsReader: OperationsReader, private idTranslator: IdTranslator, - private appDeployments: AppDeployments, @Inject(SCHEMA_MODULE_CONFIG) private schemaModuleConfig: SchemaModuleConfig, singleModel: SingleModel, compositeModel: CompositeModel, @@ -282,81 +280,6 @@ export class SchemaPublisher { }; } - private async enrichBreakingChangesWithAffectedAppDeployments(args: { - targetId: string; - breakingChanges: SchemaChangeType[] | null; - }): Promise { - if (!args.breakingChanges?.length) { - return; - } - - const schemaCoordinates = new Set(); - for (const change of args.breakingChanges) { - const coordinate = change.breakingChangeSchemaCoordinate ?? change.path; - if (coordinate) { - schemaCoordinates.add(coordinate); - } - } - - if (schemaCoordinates.size === 0) { - return; - } - - this.logger.debug( - 'Checking affected app deployments for %d schema coordinates', - schemaCoordinates.size, - ); - - try { - // Query for affected app deployments - const affectedDeployments = - await this.appDeployments.getAffectedAppDeploymentsBySchemaCoordinates({ - targetId: args.targetId, - schemaCoordinates: Array.from(schemaCoordinates), - }); - - if (affectedDeployments.length === 0) { - this.logger.debug('No app deployments affected by breaking changes'); - return; - } - - this.logger.debug( - '%d app deployments affected by breaking changes', - affectedDeployments.length, - ); - - // Attach affected app deployments to each breaking change - // Only include deployments that have operations using that specific coordinate - for (const change of args.breakingChanges) { - const coordinate = change.breakingChangeSchemaCoordinate ?? change.path; - if (coordinate) { - // Filter to only include deployments that have operations for this specific coordinate - const deploymentsForCoordinate = affectedDeployments - .filter(d => d.affectedOperationsByCoordinate[coordinate]?.length > 0) - .map(d => ({ - id: d.appDeployment.id, - name: d.appDeployment.name, - version: d.appDeployment.version, - affectedOperations: d.affectedOperationsByCoordinate[coordinate], - })); - - if (deploymentsForCoordinate.length > 0) { - ( - change as { affectedAppDeployments: typeof deploymentsForCoordinate } - ).affectedAppDeployments = deploymentsForCoordinate; - } - } - } - } catch (error) { - this.logger.error( - 'Failed to fetch affected app deployments for breaking changes (targetId=%s, coordinateCount=%d): %s', - args.targetId, - schemaCoordinates.size, - error instanceof Error ? error.message : String(error), - ); - } - } - @traceFn('SchemaPublisher.internalCheck', { initAttributes: input => ({ 'hive.organization.slug': input.target?.bySelector?.organizationSlug, @@ -732,18 +655,6 @@ export class SchemaPublisher { const retention = await this.rateLimit.getRetention({ targetId: target.id }); const expiresAt = retention ? new Date(Date.now() + retention * millisecondsPerDay) : null; - // enrich breaking changes with affected app deployments - if ( - checkResult.conclusion === SchemaCheckConclusion.Failure || - checkResult.conclusion === SchemaCheckConclusion.Success - ) { - const breakingChanges = checkResult.state?.schemaChanges?.breaking ?? null; - await this.enrichBreakingChangesWithAffectedAppDeployments({ - targetId: target.id, - breakingChanges, - }); - } - if (checkResult.conclusion === SchemaCheckConclusion.Failure) { schemaCheck = await this.storage.createSchemaCheck({ schemaSDL: sdl, 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 c7eadb8c261..b5abeddc809 100644 --- a/packages/services/api/src/modules/schema/resolvers/SchemaChange.ts +++ b/packages/services/api/src/modules/schema/resolvers/SchemaChange.ts @@ -34,7 +34,7 @@ export const SchemaChange: SchemaChangeResolvers = { severityLevel: change => severityMap[change.criticality], severityReason: change => change.reason, affectedAppDeployments: change => { - if (!change.affectedAppDeployments?.length) { + if (!change.affectedAppDeployments) { return null; } return change.affectedAppDeployments.map(d => ({ From 8785017b1943403a2fc987528fbbf2fd9d565e94 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Thu, 11 Dec 2025 16:43:58 +0100 Subject: [PATCH 12/17] fix and add integration tests --- .../tests/api/app-deployments.spec.ts | 442 +++++++++++++++++- 1 file changed, 417 insertions(+), 25 deletions(-) diff --git a/integration-tests/tests/api/app-deployments.spec.ts b/integration-tests/tests/api/app-deployments.spec.ts index 0cf136a8a3e..9b96aa688be 100644 --- a/integration-tests/tests/api/app-deployments.spec.ts +++ b/integration-tests/tests/api/app-deployments.spec.ts @@ -545,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 () => { @@ -1011,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 () => { @@ -1443,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 () => { @@ -2765,6 +2765,7 @@ const SchemaCheckWithAffectedAppDeployments = graphql(` node { message path + isSafeBasedOnUsage affectedAppDeployments { id name @@ -3330,7 +3331,7 @@ test('retired app deployments are excluded from affected deployments', async () // Retired deployment should NOT appear in affected deployments expect(helloRemoval).toBeDefined(); - expect(helloRemoval?.node.affectedAppDeployments).toBeNull(); + expect(helloRemoval?.node.affectedAppDeployments).toEqual([]); }); test('pending (non-activated) app deployments are excluded from affected deployments', async () => { @@ -3462,7 +3463,7 @@ test('pending (non-activated) app deployments are excluded from affected deploym // Pending (non-activated) deployment should NOT appear in affected deployments expect(helloRemoval).toBeDefined(); - expect(helloRemoval?.node.affectedAppDeployments).toBeNull(); + expect(helloRemoval?.node.affectedAppDeployments).toEqual([]); }); test('multiple deployments affected by same breaking change all appear', async () => { @@ -3878,3 +3879,394 @@ test('multiple operations in same deployment affected by same change', async () 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); +}); From c0c201125ee2a7c81550c6b5405cfb475c1b5bdb Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Thu, 11 Dec 2025 17:45:32 +0100 Subject: [PATCH 13/17] prettier fixes --- integration-tests/tests/api/app-deployments.spec.ts | 5 ++--- .../api/src/modules/schema/providers/models/single.ts | 6 +++++- .../api/src/modules/schema/providers/registry-checks.ts | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/integration-tests/tests/api/app-deployments.spec.ts b/integration-tests/tests/api/app-deployments.spec.ts index 9b96aa688be..639c873a36b 100644 --- a/integration-tests/tests/api/app-deployments.spec.ts +++ b/integration-tests/tests/api/app-deployments.spec.ts @@ -3972,9 +3972,8 @@ test('schema check fails if breaking change affects app deployment even when usa valid: true, }); - const baselineSchemaCheckId = ( - baselineCheck.schemaCheck as { schemaCheck: { id: string } } - ).schemaCheck.id; + const baselineSchemaCheckId = (baselineCheck.schemaCheck as { schemaCheck: { id: string } }) + .schemaCheck.id; const baselineDetails = await execute({ document: SchemaCheckWithAffectedAppDeployments, variables: { 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 5a9338a44a3..f40c5dccc26 100644 --- a/packages/services/api/src/modules/schema/providers/models/single.ts +++ b/packages/services/api/src/modules/schema/providers/models/single.ts @@ -2,7 +2,11 @@ import { Injectable, Scope } from 'graphql-modules'; import { traceFn } from '@hive/service-common'; import { SchemaChangeType } from '@hive/storage'; import { AppDeployments } from '../../../app-deployments/providers/app-deployments'; -import { ConditionalBreakingChangeDiffConfig, GetAffectedAppDeployments, RegistryChecks } from '../registry-checks'; +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'; 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 14710cb02bb..3da8db3c57c 100644 --- a/packages/services/api/src/modules/schema/providers/registry-checks.ts +++ b/packages/services/api/src/modules/schema/providers/registry-checks.ts @@ -2,7 +2,6 @@ import { URL } from 'node:url'; import { type GraphQLSchema } from 'graphql'; import { Injectable, Scope } from 'graphql-modules'; import hashObject from 'object-hash'; -import * as Sentry from '@sentry/node'; import { ChangeType, CriticalityLevel, TypeOfChangeType } from '@graphql-inspector/core'; import type { CheckPolicyResponse } from '@hive/policy'; import type { CompositionFailureError, ContractsInputType } from '@hive/schema'; @@ -12,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'; From 68fa8bf184eeee904de3941ce3950061edfd8d01 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Thu, 11 Dec 2025 18:35:33 +0100 Subject: [PATCH 14/17] add affected deployments and operations to cli /w integration test --- integration-tests/testkit/cli.ts | 16 +++++ integration-tests/tests/cli/schema.spec.ts | 66 +++++++++++++++++++- packages/libraries/cli/src/helpers/schema.ts | 19 ++++++ 3 files changed, 99 insertions(+), 2 deletions(-) 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/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..eac3ccfeb09 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,17 @@ 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}`, + ); + }); + } }); }; From 1f58dea260b0ad2579f9e09033d237d805b1eab1 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Thu, 11 Dec 2025 18:37:17 +0100 Subject: [PATCH 15/17] prettier fixes --- packages/libraries/cli/src/helpers/schema.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/libraries/cli/src/helpers/schema.ts b/packages/libraries/cli/src/helpers/schema.ts index eac3ccfeb09..51e6555be85 100644 --- a/packages/libraries/cli/src/helpers/schema.ts +++ b/packages/libraries/cli/src/helpers/schema.ts @@ -89,9 +89,7 @@ export const renderChanges = (maskedChanges: FragmentType { const ops = deployment.affectedOperations; - const opNames = ops - .map(op => op.name ?? `unnamed (${op.hash.slice(0, 7)})`) - .join(', '); + 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}`, ); From 196af346158b1182295e4e1c4415ae17c001b835 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Thu, 11 Dec 2025 18:39:12 +0100 Subject: [PATCH 16/17] update snapshot, fix failing test --- integration-tests/tests/api/schema/delete.spec.ts | 1 + 1 file changed, 1 insertion(+) 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, From 49055d7b0285b765dbcb6263fc5421a5abcc22af Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Fri, 12 Dec 2025 13:31:26 +0100 Subject: [PATCH 17/17] use single clickhouse query --- .../providers/app-deployments.ts | 135 ++++++------------ 1 file changed, 45 insertions(+), 90 deletions(-) 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 2ece6f4392d..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 @@ -844,81 +844,48 @@ export class AppDeployments { args.schemaCoordinates.length, ); - let activeDeploymentsResult; + let result; try { - 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 - LIMIT 1000 - `); - } catch (error) { - this.logger.error( - 'Failed to query active app deployments from PostgreSQL (targetId=%s): %s', - args.targetId, - error instanceof Error ? error.message : String(error), - ); - throw error; - } - - const activeDeployments = activeDeploymentsResult.rows.map(row => - AppDeploymentModel.parse(row), - ); - - if (activeDeployments.length === 0) { - this.logger.debug('No active app deployments found (targetId=%s)', args.targetId); - return []; - } - - const deploymentIds = activeDeployments.map(d => d.id); - - let affectedDocumentsResult; - try { - affectedDocumentsResult = await this.clickhouse.query({ + result = await this.clickhouse.query({ query: cSql` SELECT - "app_deployment_id" AS "appDeploymentId" - , "document_hash" AS "hash" - , "operation_name" AS "operationName" - , arrayIntersect("schema_coordinates", ${cSql.array(args.schemaCoordinates, 'String')}) AS "matchingCoordinates" - FROM - "app_deployment_documents" - WHERE - "app_deployment_id" IN (${cSql.array(deploymentIds, 'String')}) - AND hasAny("schema_coordinates", ${cSql.array(args.schemaCoordinates, 'String')}) - ORDER BY "app_deployment_id", "document_hash" - LIMIT 1 BY "app_deployment_id", "document_hash" - LIMIT 10000 + 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, deploymentCount=%d, coordinateCount=%d): %s', + 'Failed to query affected documents from ClickHouse (targetId=%s, coordinateCount=%d): %s', args.targetId, - deploymentIds.length, args.schemaCoordinates.length, error instanceof Error ? error.message : String(error), ); throw error; } - const AffectedDocumentModel = z.object({ + const AggregatedDeploymentModel = z.object({ appDeploymentId: z.string(), - hash: z.string(), - operationName: z.string().transform(value => (value === '' ? null : value)), + appName: z.string(), + appVersion: z.string(), + operations: z.array(z.tuple([z.string(), z.string()])), matchingCoordinates: z.array(z.string()), }); - const affectedDocuments = z.array(AffectedDocumentModel).parse(affectedDocumentsResult.data); + const aggregatedDeployments = z.array(AggregatedDeploymentModel).parse(result.data); - if (affectedDocuments.length === 0) { + if (aggregatedDeployments.length === 0) { this.logger.debug( 'No affected operations found (targetId=%s, coordinateCount=%d)', args.targetId, @@ -927,49 +894,37 @@ export class AppDeployments { return []; } - const deploymentIdToDeployment = new Map(activeDeployments.map(d => [d.id, d])); - // Map: deploymentId -> coordinate -> operations - const deploymentCoordinateOperations = new Map< - string, - Map> - >(); - - for (const doc of affectedDocuments) { - let coordinateMap = deploymentCoordinateOperations.get(doc.appDeploymentId); - if (!coordinateMap) { - coordinateMap = new Map(); - deploymentCoordinateOperations.set(doc.appDeploymentId, coordinateMap); - } - - for (const coordinate of doc.matchingCoordinates) { - const ops = coordinateMap.get(coordinate) ?? []; - ops.push({ - hash: doc.hash, - name: doc.operationName, - }); - coordinateMap.set(coordinate, ops); + 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; } - } - const result = []; - for (const [deploymentId, coordinateMap] of deploymentCoordinateOperations) { - const deployment = deploymentIdToDeployment.get(deploymentId); - if (deployment) { - result.push({ - appDeployment: deployment, - affectedOperationsByCoordinate: Object.fromEntries(coordinateMap), - }); - } - } + return { + appDeployment: { + id: row.appDeploymentId, + name: row.appName, + version: row.appVersion, + }, + affectedOperationsByCoordinate: operationsByCoordinate, + }; + }); this.logger.debug( - 'Found %d affected app deployments with %d total operations (targetId=%s)', - result.length, - affectedDocuments.length, + 'Found %d affected app deployments (targetId=%s)', + results.length, args.targetId, ); - return result; + return results; } async getActiveAppDeployments(args: {