mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
feat(api): retrieve app deployments based on last used data (#7377)
This commit is contained in:
parent
bf971912c6
commit
8549f22240
6 changed files with 1212 additions and 9 deletions
14
.changeset/jdpj-gvmv-utrp.md
Normal file
14
.changeset/jdpj-gvmv-utrp.md
Normal 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.
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue