From 447cf0fea8769e0ff487bd4947d7aeeab32cead2 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Fri, 5 Dec 2025 11:28:12 +0100 Subject: [PATCH 01/10] 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 0562d74131acb4f1c2d9e32bbd6f11419ed15abb Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 8 Dec 2025 14:06:46 +0100 Subject: [PATCH 02/10] 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 39964a54dea..4c73e89cc9f 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 23a7d2722e484954100e37a9a47baf43d6a42bce Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 8 Dec 2025 14:12:37 +0100 Subject: [PATCH 03/10] 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 e15350128154c34a687170c23d4bdf9c569b5862 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 8 Dec 2025 14:33:46 +0100 Subject: [PATCH 04/10] 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 c4dd1923d695b1e4529e20174c8e7ea5931e7744 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 8 Dec 2025 14:41:30 +0100 Subject: [PATCH 05/10] 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 861a9fafe759d574b2e10061f8be9119ee8a747e Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 8 Dec 2025 14:43:03 +0100 Subject: [PATCH 06/10] 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 d9f22f4e77d59a9e4dab09aabdd41549ec8c563c Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 8 Dec 2025 15:01:36 +0100 Subject: [PATCH 07/10] 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 aa828b68d1f61d9da709e43d508667286b4ecf61 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 8 Dec 2025 18:11:42 +0100 Subject: [PATCH 08/10] 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 23b2d545c494bde3c75ed809fcb3326ea7df51fc Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Wed, 10 Dec 2025 13:02:54 +0100 Subject: [PATCH 09/10] 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 622e248b8b6ff9fb7e9c1d14a91da8c97f15f880 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Wed, 10 Dec 2025 13:06:03 +0100 Subject: [PATCH 10/10] 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