From e02a9ed63d97ce48148ac1bb4616965844778bae 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 | 682 ++++++++++++++++++ .../modules/app-deployments/module.graphql.ts | 40 + .../providers/app-deployments-manager.ts | 20 + .../providers/app-deployments.ts | 156 ++++ .../app-deployments/resolvers/Target.ts | 13 +- .../schema-registry/app-deployments.mdx | 82 +++ 7 files changed, 1006 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..d975ca01186 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,654 @@ 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); +}); 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..767837db061 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,31 @@ 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. + """ + input ActiveAppDeploymentsFilter { + """ + Filter by app deployment name. Case-insensitive partial match. + Combined with AND semantics (narrows down results from date filters). + """ + 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 +101,17 @@ 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. + 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..975d09a427a 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,162 @@ 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; + const cursor = args.cursor ? decodeCreatedAtAndUUIDIdBasedCursor(args.cursor) : null; + + // Get all active deployments from db + 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" + `); + + const activeDeployments = activeDeploymentsResult.rows.map(row => + AppDeploymentModel.parse(row), + ); + + 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); + const usageData = await this.getLastUsedForAppDeployments({ + appDeploymentIds: deploymentIds, + }); + + // 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 + const filteredDeployments = activeDeployments.filter(deployment => { + 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 (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 (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 faaf0539e0e2b17e758850296fd4496a2cde2abc Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Fri, 5 Dec 2025 11:39:29 +0100 Subject: [PATCH 02/10] return all active deployments when no filter is provided --- .../api/src/modules/app-deployments/module.graphql.ts | 4 +++- .../app-deployments/providers/app-deployments.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) 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 767837db061..86f24437520 100644 --- a/packages/services/api/src/modules/app-deployments/module.graphql.ts +++ b/packages/services/api/src/modules/app-deployments/module.graphql.ts @@ -70,11 +70,12 @@ export default gql` 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. - Combined with AND semantics (narrows down results from date filters). + Applied with AND semantics to narrow down results. """ name: String """ @@ -104,6 +105,7 @@ export default gql` """ 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). """ 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 975d09a427a..6e3a13ac9b2 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 @@ -900,8 +900,16 @@ export class AppDeployments { lastUsedMap.set(usage.appDeploymentId, usage.lastUsed); } - // Apply OR filter logic + // 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; From 9861290c0016a3e5a5c971f20513d9603e7bb484 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Fri, 5 Dec 2025 11:46:51 +0100 Subject: [PATCH 03/10] throw error when cursor or chsql/psql query fails --- .../providers/app-deployments.ts | 75 ++++++++++++++----- 1 file changed, 55 insertions(+), 20 deletions(-) diff --git a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts index 6e3a13ac9b2..daa85ee778c 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 @@ -850,25 +850,49 @@ export class AppDeployments { } const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20; - const cursor = args.cursor ? decodeCreatedAtAndUUIDIdBasedCursor(args.cursor) : null; - // Get all active deployments from db - 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" - `); + let cursor; + 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.`, + ); + } + } - const activeDeployments = activeDeploymentsResult.rows.map(row => - AppDeploymentModel.parse(row), - ); + // Get all active deployments from db + 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" + `); + + 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)', @@ -890,9 +914,20 @@ export class AppDeployments { // Get lastUsed data from clickhouse for all active deployment IDs const deploymentIds = activeDeployments.map(d => d.id); - const usageData = await this.getLastUsedForAppDeployments({ - appDeploymentIds: deploymentIds, - }); + 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(); From 3d0aaab4d52ea6301dbeb1d777e6b87712579920 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Fri, 5 Dec 2025 11:50:26 +0100 Subject: [PATCH 04/10] test for invalid date filter --- .../tests/api/app-deployments.spec.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/integration-tests/tests/api/app-deployments.spec.ts b/integration-tests/tests/api/app-deployments.spec.ts index d975ca01186..9047b7871ee 100644 --- a/integration-tests/tests/api/app-deployments.spec.ts +++ b/integration-tests/tests/api/app-deployments.spec.ts @@ -2517,3 +2517,29 @@ test('activeAppDeployments pagination with first and after', async () => { 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); +}); From f0f1c88d0b8dc7e26690cf23692529a4fa0a7efa Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Fri, 5 Dec 2025 11:57:36 +0100 Subject: [PATCH 05/10] fix cursor, hard limit to 1000 rows per query --- .../modules/app-deployments/providers/app-deployments.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts index daa85ee778c..f9e998bf50f 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 @@ -851,7 +851,7 @@ export class AppDeployments { const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20; - let cursor; + let cursor = null; if (args.cursor) { try { cursor = decodeCreatedAtAndUUIDIdBasedCursor(args.cursor); @@ -868,7 +868,8 @@ export class AppDeployments { } } - // Get all active deployments from db + // Get active deployments from db + const maxDeployments = 1000; // note: hard limit let activeDeployments; try { const activeDeploymentsResult = await this.pool.query(sql` @@ -882,6 +883,7 @@ export class AppDeployments { 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)); From 7fa54ce78412b91026e7bfa06f3d7970b9a89b83 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Fri, 5 Dec 2025 12:12:50 +0100 Subject: [PATCH 06/10] validate filter dates (defensive code) --- .../app-deployments/providers/app-deployments.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 f9e998bf50f..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 @@ -954,6 +954,11 @@ export class AppDeployments { 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; } @@ -963,6 +968,11 @@ export class AppDeployments { 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; } From 4ebb342591669fa6f691fa557a34a0df004c7c63 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Fri, 5 Dec 2025 12:19:21 +0100 Subject: [PATCH 07/10] test name+lastUserBefore --- .../tests/api/app-deployments.spec.ts | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/integration-tests/tests/api/app-deployments.spec.ts b/integration-tests/tests/api/app-deployments.spec.ts index 9047b7871ee..7076e6b0702 100644 --- a/integration-tests/tests/api/app-deployments.spec.ts +++ b/integration-tests/tests/api/app-deployments.spec.ts @@ -2543,3 +2543,132 @@ test('activeAppDeployments returns error for invalid date filter', async () => { 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'); +}); From 16aea1d6a6e847a02f2178e50e89b4eb027bb17c Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Fri, 5 Dec 2025 12:30:32 +0100 Subject: [PATCH 08/10] test pagination clamp, reach higher code coverage --- .../tests/api/app-deployments.spec.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/integration-tests/tests/api/app-deployments.spec.ts b/integration-tests/tests/api/app-deployments.spec.ts index 7076e6b0702..5d360dfc33b 100644 --- a/integration-tests/tests/api/app-deployments.spec.ts +++ b/integration-tests/tests/api/app-deployments.spec.ts @@ -2672,3 +2672,71 @@ test('activeAppDeployments filters by name combined with lastUsedBefore', async 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); +}); From 7f6f96b446df3947df9e67867e7a81dc417ee9a4 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Tue, 9 Dec 2025 23:25:35 +0100 Subject: [PATCH 09/10] add needed public tag annotations --- .../api/src/modules/app-deployments/module.graphql.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 86f24437520..3b7588690f4 100644 --- a/packages/services/api/src/modules/app-deployments/module.graphql.ts +++ b/packages/services/api/src/modules/app-deployments/module.graphql.ts @@ -110,10 +110,10 @@ export default gql` Only active deployments are returned (not pending or retired). """ activeAppDeployments( - first: Int - after: String - filter: ActiveAppDeploymentsFilter! - ): AppDeploymentConnection! + first: Int @tag(name: "public") + after: String @tag(name: "public") + filter: ActiveAppDeploymentsFilter! @tag(name: "public") + ): AppDeploymentConnection! @tag(name: "public") } extend type Mutation { From 2bf1323f513fc9673763a1c92224216033d8554b Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Tue, 9 Dec 2025 23:25:45 +0100 Subject: [PATCH 10/10] remove docs --- .../schema-registry/app-deployments.mdx | 82 ------------------- 1 file changed, 82 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 e76aea71ed9..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,88 +258,6 @@ 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