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..3b7588690f4 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 @tag(name: "public") + after: String @tag(name: "public") + filter: ActiveAppDeploymentsFilter! @tag(name: "public") + ): AppDeploymentConnection! @tag(name: "public") } 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, + }, + }); + }, };