feat(api): retrieve app deployments based on last used data (#7377)

This commit is contained in:
Adam Benhassen 2026-01-15 16:11:11 +02:00 committed by GitHub
parent bf971912c6
commit 8549f22240
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1212 additions and 9 deletions

View file

@ -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.

View file

@ -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);
});

View file

@ -2,9 +2,9 @@ import { gql } from 'graphql-modules';
export default gql`
type AppDeployment {
id: ID!
name: String!
version: String!
id: ID! @tag(name: "public")
name: String! @tag(name: "public")
version: String! @tag(name: "public")
documents(
first: Int
after: String
@ -13,9 +13,13 @@ export default gql`
totalDocumentCount: Int!
status: AppDeploymentStatus!
"""
The timestamp when the app deployment was created.
"""
createdAt: DateTime! @tag(name: "public")
"""
The last time a GraphQL request that used the app deployment was reported.
"""
lastUsed: DateTime
lastUsed: DateTime @tag(name: "public")
}
extend type Organization {
@ -49,19 +53,45 @@ export default gql`
}
type AppDeploymentConnection {
pageInfo: PageInfo!
edges: [AppDeploymentEdge!]!
pageInfo: PageInfo! @tag(name: "public")
edges: [AppDeploymentEdge!]! @tag(name: "public")
}
type AppDeploymentEdge {
cursor: String!
node: AppDeployment!
cursor: String! @tag(name: "public")
node: AppDeployment! @tag(name: "public")
}
input AppDeploymentDocumentsFilterInput {
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 @tag(name: "public")
"""
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 @tag(name: "public")
"""
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 @tag(name: "public")
}
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 {

View file

@ -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<AppDeploymentRecord, number>(async args => {
const appDeploymentIds = args.map(appDeployment => appDeployment.id);
const counts = await this.appDeployments.getDocumentCountForAppDeployments({

View file

@ -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<unknown>(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<string, string>();
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`

View file

@ -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,
},
});
},
};