Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Composer } from '../../composition/composer.js';
import { AuditLogRepository } from '../../repositories/AuditLogRepository.js';
import { ContractRepository } from '../../repositories/ContractRepository.js';
import { FederatedGraphRepository } from '../../repositories/FederatedGraphRepository.js';
import { FeatureFlagRepository } from '../../repositories/FeatureFlagRepository.js';
import { GraphCompositionRepository } from '../../repositories/GraphCompositionRepository.js';
import { DefaultNamespace, NamespaceRepository } from '../../repositories/NamespaceRepository.js';
import { OrganizationRepository } from '../../repositories/OrganizationRepository.js';
Expand Down Expand Up @@ -420,6 +421,7 @@ export function createProposal(
const schemaCheckRepo = new SchemaCheckRepository(opts.db);
const contractRepo = new ContractRepository(logger, opts.db, authContext.organizationId);
const graphCompostionRepo = new GraphCompositionRepository(logger, opts.db);
const featureFlagRepo = new FeatureFlagRepository(logger, opts.db, authContext.organizationId);
const trafficInspector = new SchemaUsageTrafficInspector(opts.chClient!);
const composer = new Composer(
logger,
Expand All @@ -428,6 +430,7 @@ export function createProposal(
subgraphRepo,
contractRepo,
graphCompostionRepo,
featureFlagRepo,
opts.chClient,
opts.webhookProxyUrl,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { enrichLogger, getLogger, handleError } from '../../util.js';
import { AuditLogRepository } from '../../repositories/AuditLogRepository.js';
import { ContractRepository } from '../../repositories/ContractRepository.js';
import { FederatedGraphRepository } from '../../repositories/FederatedGraphRepository.js';
import { FeatureFlagRepository } from '../../repositories/FeatureFlagRepository.js';
import { GraphCompositionRepository } from '../../repositories/GraphCompositionRepository.js';
import { DefaultNamespace, NamespaceRepository } from '../../repositories/NamespaceRepository.js';
import { OrganizationRepository } from '../../repositories/OrganizationRepository.js';
Expand Down Expand Up @@ -485,6 +486,7 @@ export function updateProposal(
const schemaCheckRepo = new SchemaCheckRepository(opts.db);
const contractRepo = new ContractRepository(logger, opts.db, authContext.organizationId);
const graphCompostionRepo = new GraphCompositionRepository(logger, opts.db);
const featureFlagRepo = new FeatureFlagRepository(logger, opts.db, authContext.organizationId);
const trafficInspector = new SchemaUsageTrafficInspector(opts.chClient!);
const composer = new Composer(
logger,
Expand All @@ -493,6 +495,7 @@ export function updateProposal(
subgraphRepo,
contractRepo,
graphCompostionRepo,
featureFlagRepo,
opts.chClient,
opts.webhookProxyUrl,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { buildSchema } from '../../composition/composition.js';
import { OpenAIGraphql } from '../../openai-graphql/index.js';
import { ContractRepository } from '../../repositories/ContractRepository.js';
import { FederatedGraphRepository } from '../../repositories/FederatedGraphRepository.js';
import { FeatureFlagRepository } from '../../repositories/FeatureFlagRepository.js';
import { GraphCompositionRepository } from '../../repositories/GraphCompositionRepository.js';
import { DefaultNamespace, NamespaceRepository } from '../../repositories/NamespaceRepository.js';
import { OrganizationRepository } from '../../repositories/OrganizationRepository.js';
Expand All @@ -33,6 +34,7 @@ export function fixSubgraphSchema(
const subgraphRepo = new SubgraphRepository(logger, opts.db, authContext.organizationId);
const contractRepo = new ContractRepository(logger, opts.db, authContext.organizationId);
const graphCompositionRepo = new GraphCompositionRepository(logger, opts.db);
const featureFlagRepo = new FeatureFlagRepository(logger, opts.db, authContext.organizationId);
const namespaceRepo = new NamespaceRepository(opts.db, authContext.organizationId);

const composer = new Composer(
Expand All @@ -42,6 +44,7 @@ export function fixSubgraphSchema(
subgraphRepo,
contractRepo,
graphCompositionRepo,
featureFlagRepo,
opts.chClient,
opts.webhookProxyUrl,
);
Expand Down
26 changes: 16 additions & 10 deletions controlplane/src/core/composition/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,15 @@ import {
RouterConfigSchema,
} from '@wundergraph/cosmo-connect/dist/node/v1/node_pb';

import type {
FeatureFlagRouterExecutionConfig,
FeatureFlagRouterExecutionConfigs,
RouterConfig,
} from '@wundergraph/cosmo-connect/dist/node/v1/node_pb';
import type { FeatureFlagRouterExecutionConfig, RouterConfig } from '@wundergraph/cosmo-connect/dist/node/v1/node_pb';

import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import { FederatedGraphDTO, Label, SubgraphDTO } from '../../types/index.js';
import { BlobStorage } from '../blobstorage/index.js';
import { audiences, nowInSeconds, signJwtHS256 } from '../crypto/jwt.js';
import { ContractRepository } from '../repositories/ContractRepository.js';
import { FederatedGraphRepository } from '../repositories/FederatedGraphRepository.js';
import { FeatureFlagRepository } from '../repositories/FeatureFlagRepository.js';
import { SubgraphRepository } from '../repositories/SubgraphRepository.js';
import {
AdmissionError,
Expand Down Expand Up @@ -157,6 +154,7 @@ export class Composer {
private subgraphRepo: SubgraphRepository,
private contractRepo: ContractRepository,
private graphCompositionRepository: GraphCompositionRepository,
private featureFlagRepo: FeatureFlagRepository,
private chClient?: ClickHouseClient,
private proxyUrl?: string,
) {}
Expand Down Expand Up @@ -462,9 +460,17 @@ export class Composer {
featureFlagId: string;
splitConfigEnabled: boolean;
}): Promise<CompositionDeployResult> {
const prevValidFederatedSDL = await this.federatedGraphRepo.getLatestValidSchemaVersion({
targetId: composedGraph.targetID,
});
// For a feature-flag composition the baseline is the previous composition of the same feature flag (not the base
// graph's latest valid version, which would produce a meaningless base-vs-feature-flag diff). Computed before
// addSchemaVersion inserts the new version, so it resolves to the chronologically prior feature-flag version.
const prevValidFederatedSDL = isFeatureFlagComposition
? await this.featureFlagRepo.getLatestValidFeatureFlagSchemaVersion({
targetId: composedGraph.targetID,
featureFlagId,
})
: await this.federatedGraphRepo.getLatestValidSchemaVersion({
targetId: composedGraph.targetID,
});

const updatedFederatedGraph = await this.federatedGraphRepo.addSchemaVersion({
targetId: composedGraph.targetID,
Expand All @@ -480,8 +486,8 @@ export class Composer {
splitConfigEnabled,
});

// If the composed schema is invalid, or it is a feature flag composition, we do not create a changelog
if (!routerExecutionConfig || !updatedFederatedGraph?.composedSchemaVersionId || isFeatureFlagComposition) {
// If the composed schema is invalid, we do not create a changelog
if (!routerExecutionConfig || !updatedFederatedGraph?.composedSchemaVersionId) {
return {
schemaVersionId: updatedFederatedGraph?.composedSchemaVersionId || '',
};
Expand Down
35 changes: 35 additions & 0 deletions controlplane/src/core/repositories/FeatureFlagRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1618,6 +1618,41 @@ export class FeatureFlagRepository {
return ffSchemaVersion;
}

public async getLatestValidFeatureFlagSchemaVersion(data: { targetId: string; featureFlagId: string }) {
const latest = await this.db
.select({
schema: schemaVersion.schemaSDL,
clientSchema: schemaVersion.clientSchema,
schemaVersionId: schemaVersion.id,
})
.from(federatedGraphsToFeatureFlagSchemaVersions)
.innerJoin(
schemaVersion,
eq(schemaVersion.id, federatedGraphsToFeatureFlagSchemaVersions.composedSchemaVersionId),
)
.innerJoin(graphCompositions, eq(graphCompositions.schemaVersionId, schemaVersion.id))
.where(
and(
eq(schemaVersion.targetId, data.targetId),
eq(schemaVersion.organizationId, this.organizationId),
eq(federatedGraphsToFeatureFlagSchemaVersions.featureFlagId, data.featureFlagId),
eq(graphCompositions.isFeatureFlagComposition, true),
eq(graphCompositions.isComposable, true),
or(isNull(graphCompositions.deploymentError), eq(graphCompositions.deploymentError, '')),
or(isNull(graphCompositions.admissionError), eq(graphCompositions.admissionError, '')),
),
)
.orderBy(desc(graphCompositions.createdAt))
.limit(1)
.execute();

if (latest.length === 0) {
return undefined;
}

return latest[0];
}

public async delete(featureFlagId: string) {
await this.db
.delete(featureFlags)
Expand Down
1 change: 1 addition & 0 deletions controlplane/src/core/repositories/SubgraphRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2515,6 +2515,7 @@ export class SubgraphRepository {
subgraphRepo,
contractRepo,
graphCompostionRepo,
featureFlagRepo,
chClient,
webhookProxyUrl,
);
Expand Down
2 changes: 2 additions & 0 deletions controlplane/src/core/services/CompositionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ export class CompositionService {
new SubgraphRepository(this.logger, this.db, this.organizationId),
new ContractRepository(this.logger, this.db, this.organizationId),
new GraphCompositionRepository(this.logger, this.db),
new FeatureFlagRepository(this.logger, this.db, this.organizationId),
this.chClient,
this.webhookProxyUrl,
);
Expand Down Expand Up @@ -1328,6 +1329,7 @@ export class CompositionService {
new SubgraphRepository(this.logger, this.db, this.organizationId),
new ContractRepository(this.logger, this.db, this.organizationId),
new GraphCompositionRepository(this.logger, this.db),
new FeatureFlagRepository(this.logger, this.db, this.organizationId),
this.chClient,
this.webhookProxyUrl,
);
Expand Down
129 changes: 129 additions & 0 deletions controlplane/test/composition/getCompositionDetails.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,4 +603,133 @@ describe('getCompositionDetails', () => {
]);
},
);

test(
'that a feature flag composition changelog reflects the schema changes against the previous feature flag composition',
getDebugTestOptions(isDebugMode),
async (testContext) => {
const { client, server } = await SetupTest({ dbname, enabledFeatures: ['split-config-loading'] });
testContext.onTestFinished(() => server.close());

const namespace = genID('namespace').toLowerCase();
const labels: Label[] = [];
const baseGraphName = genID('baseFederatedGraphName');
const ffName = genID('feature-flag');

await createNamespace(client, namespace);
await featureFlagIntegrationTestSetUp(
client,
[
{ name: 'users', hasFeatureSubgraph: true },
{ name: 'products-standalone', hasFeatureSubgraph: true },
],
baseGraphName,
labels,
namespace,
);

await createFeatureFlag(client, ffName, labels, ['products-standalone-feature'], namespace, true);

// Update the feature subgraph with a genuinely new client-visible field, producing a feature flag composition
// whose supergraph differs from the previous (creation) one.
const updateSubgraphResp = await client.publishFederatedSubgraph({
name: 'products-standalone-feature',
namespace,
schema: `
type Product @key(fields: "upc sku") {
upc: Int!
sku: String!
details: String!
isPremium: Boolean! @tag(name: "exclude")
newField: String!
changelogTestField: String!
}

type Query {
products: [Product!]!
}
`,
});
expect(updateSubgraphResp.response?.code).toBe(EnumStatusCode.OK);

const compositionsResp = await client.getCompositions({
fedGraphName: baseGraphName,
namespace,
startDate: formatISO(subDays(new Date(), 1)),
endDate: formatISO(addMinutes(new Date(), 1)),
excludeFeatureFlagCompositions: false,
});
expect(compositionsResp.response?.code).toBe(EnumStatusCode.OK);

// The latest composition is the feature flag composition triggered by the feature subgraph update.
const latestFfComposition = compositionsResp.compositions[0];
expect(latestFfComposition.isFeatureFlagComposition).toBe(true);
expect(latestFfComposition.featureFlagName).toBe(ffName);

const detailsResp = await client.getCompositionDetails({
compositionId: latestFfComposition.id,
namespace,
});
expect(detailsResp.response?.code).toBe(EnumStatusCode.OK);
// The changelog is diffed against the PREVIOUS feature flag composition of the same flag (not the base graph),
// so the single added field (`changelogTestField`) must be reflected as exactly one addition and no deletions.
expect(detailsResp.changeCounts?.additions).toBe(2);
expect(detailsResp.changeCounts?.deletions).toBe(0);
},
);

test(
'that recomposing a feature flag without schema changes produces an empty changelog',
getDebugTestOptions(isDebugMode),
async (testContext) => {
const { client, server } = await SetupTest({ dbname, enabledFeatures: ['split-config-loading'] });
testContext.onTestFinished(() => server.close());

const namespace = genID('namespace').toLowerCase();
const labels: Label[] = [];
const baseGraphName = genID('baseFederatedGraphName');
const ffName = genID('feature-flag');

await createNamespace(client, namespace);
await featureFlagIntegrationTestSetUp(
client,
[
{ name: 'users', hasFeatureSubgraph: true },
{ name: 'products-standalone', hasFeatureSubgraph: true },
],
baseGraphName,
labels,
namespace,
);

await createFeatureFlag(client, ffName, labels, ['products-standalone-feature'], namespace, true);

// Recompose the feature flag without changing any schema; this forces a new feature flag composition.
const recomposeResp = await client.recomposeFeatureFlag({ name: ffName, namespace });
expect(recomposeResp.response?.code).toBe(EnumStatusCode.OK);

const compositionsResp = await client.getCompositions({
fedGraphName: baseGraphName,
namespace,
startDate: formatISO(subDays(new Date(), 1)),
endDate: formatISO(addMinutes(new Date(), 1)),
excludeFeatureFlagCompositions: false,
});
expect(compositionsResp.response?.code).toBe(EnumStatusCode.OK);

// The latest composition is the recompose, whose supergraph is identical to the previous feature flag
// composition. A no-op recompose must not add anything to the changelog.
const recomposition = compositionsResp.compositions[0];
expect(recomposition.isFeatureFlagComposition).toBe(true);
expect(recomposition.featureFlagName).toBe(ffName);

const detailsResp = await client.getCompositionDetails({
compositionId: recomposition.id,
namespace,
});
expect(detailsResp.response?.code).toBe(EnumStatusCode.OK);
expect(detailsResp.changeCounts?.additions ?? 0).toBe(0);
expect(detailsResp.changeCounts?.deletions ?? 0).toBe(0);
},
);
});
Loading