@featurevisor/core 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/builder.ts CHANGED
@@ -23,11 +23,12 @@ import {
23
23
  FeatureKey,
24
24
  Allocation,
25
25
  EnvironmentKey,
26
+ Group,
27
+ Range,
26
28
  } from "@featurevisor/types";
27
- import { MAX_BUCKETED_NUMBER } from "@featurevisor/sdk";
28
29
 
29
30
  import { SCHEMA_VERSION, ProjectConfig } from "./config";
30
- import { getNewTraffic } from "./traffic";
31
+ import { getTraffic } from "./traffic";
31
32
  import {
32
33
  parseYaml,
33
34
  extractAttributeKeysFromConditions,
@@ -73,6 +74,49 @@ export function buildDatafile(
73
74
 
74
75
  const segmentKeysUsedByTag = new Set<SegmentKey>();
75
76
  const attributeKeysUsedByTag = new Set<AttributeKey>();
77
+ const featureRanges = new Map<FeatureKey, Range[]>();
78
+
79
+ // groups
80
+ const groups: Group[] = [];
81
+ if (fs.existsSync(projectConfig.groupsDirectoryPath)) {
82
+ const groupFiles = fs
83
+ .readdirSync(projectConfig.groupsDirectoryPath)
84
+ .filter((f) => f.endsWith(".yml"));
85
+
86
+ for (const groupFile of groupFiles) {
87
+ const groupKey = path.basename(groupFile, ".yml");
88
+ const groupFilePath = path.join(projectConfig.groupsDirectoryPath, groupFile);
89
+ const parsedGroup = parseYaml(fs.readFileSync(groupFilePath, "utf8")) as Group;
90
+
91
+ groups.push({
92
+ ...parsedGroup,
93
+ key: groupKey,
94
+ });
95
+
96
+ let accumulatedPercentage = 0;
97
+ parsedGroup.slots.forEach(function (slot, slotIndex) {
98
+ const isFirstSlot = slotIndex === 0;
99
+ const isLastSlot = slotIndex === parsedGroup.slots.length - 1;
100
+
101
+ if (slot.feature) {
102
+ const featureKey = slot.feature;
103
+ const featureRangesForFeature = featureRanges.get(featureKey) || [];
104
+
105
+ const start = isFirstSlot ? accumulatedPercentage : accumulatedPercentage + 1;
106
+ const end = accumulatedPercentage + slot.percentage * 1000;
107
+
108
+ featureRangesForFeature.push({
109
+ start,
110
+ end,
111
+ });
112
+
113
+ featureRanges.set(slot.feature, featureRangesForFeature);
114
+ }
115
+
116
+ accumulatedPercentage += slot.percentage * 1000;
117
+ });
118
+ }
119
+ }
76
120
 
77
121
  // features
78
122
  const features: Feature[] = [];
@@ -115,27 +159,9 @@ export function buildDatafile(
115
159
  continue;
116
160
  }
117
161
 
118
- const featureTraffic: Traffic[] = [];
119
-
120
162
  for (const parsedRule of parsedFeature.environments[options.environment].rules) {
121
163
  const extractedSegmentKeys = extractSegmentKeysFromGroupSegments(parsedRule.segments);
122
164
  extractedSegmentKeys.forEach((segmentKey) => segmentKeysUsedByTag.add(segmentKey));
123
-
124
- const traffic: Traffic = {
125
- key: parsedRule.key,
126
- segments:
127
- typeof parsedRule.segments === "string"
128
- ? parsedRule.segments
129
- : JSON.stringify(parsedRule.segments),
130
- percentage: parsedRule.percentage * (MAX_BUCKETED_NUMBER / 100),
131
- allocation: [],
132
- };
133
-
134
- if (parsedRule.variables) {
135
- traffic.variables = parsedRule.variables;
136
- }
137
-
138
- featureTraffic.push(traffic);
139
165
  }
140
166
 
141
167
  const feature: Feature = {
@@ -145,6 +171,7 @@ export function buildDatafile(
145
171
  variations: parsedFeature.variations.map((variation: Variation) => {
146
172
  const mappedVariation: any = {
147
173
  value: variation.value,
174
+ weight: variation.weight, // @TODO: added so state files can maintain weight info, but datafiles don't need this. find a way to remove it from datafiles later
148
175
  };
149
176
 
150
177
  if (!variation.variables) {
@@ -196,10 +223,11 @@ export function buildDatafile(
196
223
 
197
224
  return mappedVariation;
198
225
  }),
199
- traffic: getNewTraffic(
226
+ traffic: getTraffic(
200
227
  parsedFeature.variations,
201
228
  parsedFeature.environments[options.environment].rules,
202
229
  existingFeatures && existingFeatures[featureKey],
230
+ featureRanges.get(featureKey) || [],
203
231
  ),
204
232
  };
205
233
 
@@ -315,15 +343,17 @@ export function buildDatafile(
315
343
  traffic: feature.traffic.map((t: Traffic) => {
316
344
  return {
317
345
  key: t.key,
318
- percentage: t.percentage,
346
+ percentage: t.percentage, // @TODO: remove this in next breaking semver
319
347
  allocation: t.allocation.map((a: Allocation) => {
320
348
  return {
321
349
  variation: a.variation,
322
- percentage: a.percentage,
350
+ percentage: a.percentage, // @TODO: remove this in next breaking semver
351
+ range: a.range,
323
352
  };
324
353
  }),
325
354
  };
326
355
  }),
356
+ ranges: featureRanges.get(feature.key) || undefined,
327
357
  };
328
358
 
329
359
  acc[feature.key] = item;
package/src/config.ts CHANGED
@@ -5,6 +5,7 @@ import { BucketBy } from "@featurevisor/types";
5
5
  export const FEATURES_DIRECTORY_NAME = "features";
6
6
  export const SEGMENTS_DIRECTORY_NAME = "segments";
7
7
  export const ATTRIBUTES_DIRECTORY_NAME = "attributes";
8
+ export const GROUPS_DIRECTORY_NAME = "groups";
8
9
  export const TESTS_DIRECTORY_NAME = "tests";
9
10
  export const STATE_DIRECTORY_NAME = ".featurevisor";
10
11
  export const OUTPUT_DIRECTORY_NAME = "dist";
@@ -23,6 +24,7 @@ export interface ProjectConfig {
23
24
  featuresDirectoryPath: string;
24
25
  segmentsDirectoryPath: string;
25
26
  attributesDirectoryPath: string;
27
+ groupsDirectoryPath: string;
26
28
  testsDirectoryPath: string;
27
29
  stateDirectoryPath: string;
28
30
  outputDirectoryPath: string;
@@ -39,6 +41,7 @@ export function getProjectConfig(rootDirectoryPath: string): ProjectConfig {
39
41
  featuresDirectoryPath: path.join(rootDirectoryPath, FEATURES_DIRECTORY_NAME),
40
42
  segmentsDirectoryPath: path.join(rootDirectoryPath, SEGMENTS_DIRECTORY_NAME),
41
43
  attributesDirectoryPath: path.join(rootDirectoryPath, ATTRIBUTES_DIRECTORY_NAME),
44
+ groupsDirectoryPath: path.join(rootDirectoryPath, GROUPS_DIRECTORY_NAME),
42
45
  testsDirectoryPath: path.join(rootDirectoryPath, TESTS_DIRECTORY_NAME),
43
46
 
44
47
  stateDirectoryPath: path.join(rootDirectoryPath, STATE_DIRECTORY_NAME),
package/src/linter.ts CHANGED
@@ -7,6 +7,7 @@ import * as Joi from "joi";
7
7
  import { getYAMLFiles, parseYaml } from "./utils";
8
8
 
9
9
  import { ProjectConfig } from "./config";
10
+ import { ParsedFeature } from "@featurevisor/types";
10
11
 
11
12
  export function getAttributeJoiSchema(projectConfig: ProjectConfig) {
12
13
  const attributeJoiSchema = Joi.object({
@@ -101,6 +102,60 @@ export function getSegmentJoiSchema(projectConfig: ProjectConfig, conditionsJoiS
101
102
  return segmentJoiSchema;
102
103
  }
103
104
 
105
+ export function getGroupJoiSchema(projectConfig: ProjectConfig) {
106
+ const groupJoiSchema = Joi.object({
107
+ description: Joi.string().required(),
108
+ slots: Joi.array()
109
+ .items(
110
+ Joi.object({
111
+ feature: Joi.string(),
112
+ percentage: Joi.number().precision(3).min(0).max(100),
113
+ }),
114
+ )
115
+ .custom(function (value, helper) {
116
+ const totalPercentage = value.reduce((acc, slot) => acc + slot.percentage, 0);
117
+
118
+ if (totalPercentage !== 100) {
119
+ throw new Error("total percentage is not 100");
120
+ }
121
+
122
+ for (const slot of value) {
123
+ const maxPercentageForRule = slot.percentage;
124
+
125
+ if (slot.feature) {
126
+ const featureKey = slot.feature;
127
+ const featurePath = path.join(projectConfig.featuresDirectoryPath, `${featureKey}.yml`);
128
+ const parsedFeature = parseYaml(fs.readFileSync(featurePath, "utf8")) as ParsedFeature;
129
+
130
+ if (!parsedFeature) {
131
+ throw new Error(`feature ${featureKey} not found`);
132
+ }
133
+
134
+ const environmentKeys = Object.keys(parsedFeature.environments);
135
+ for (const environmentKey of environmentKeys) {
136
+ const environment = parsedFeature.environments[environmentKey];
137
+ const rules = environment.rules;
138
+
139
+ for (const rule of rules) {
140
+ if (rule.percentage > maxPercentageForRule) {
141
+ // @TODO: this does not help with same feature belonging to multiple slots. fix that.
142
+ throw new Error(
143
+ `Feature ${featureKey}'s rule ${rule.key} in ${environmentKey} has a percentage of ${rule.percentage} which is greater than the maximum percentage of ${maxPercentageForRule} for the slot`,
144
+ );
145
+ }
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ return value;
152
+ })
153
+ .required(),
154
+ });
155
+
156
+ return groupJoiSchema;
157
+ }
158
+
104
159
  export function getFeatureJoiSchema(projectConfig: ProjectConfig, conditionsJoiSchema) {
105
160
  const variationValueJoiSchema = Joi.alternatives().try(Joi.string(), Joi.number(), Joi.boolean());
106
161
  const variableValueJoiSchema = Joi.alternatives()
@@ -350,6 +405,33 @@ export async function lintProject(projectConfig: ProjectConfig): Promise<boolean
350
405
  }
351
406
  }
352
407
 
408
+ // lint groups
409
+ console.log("\nLinting groups...\n");
410
+ if (fs.existsSync(projectConfig.groupsDirectoryPath)) {
411
+ const groupFilePaths = getYAMLFiles(path.join(projectConfig.groupsDirectoryPath));
412
+ const groupJoiSchema = getGroupJoiSchema(projectConfig);
413
+
414
+ for (const filePath of groupFilePaths) {
415
+ const key = path.basename(filePath, ".yml");
416
+ const parsed = parseYaml(fs.readFileSync(filePath, "utf8")) as any;
417
+ console.log(" =>", key);
418
+
419
+ try {
420
+ await groupJoiSchema.validateAsync(parsed);
421
+ } catch (e) {
422
+ if (e instanceof Joi.ValidationError) {
423
+ printJoiError(e);
424
+ } else {
425
+ console.log(e);
426
+ }
427
+
428
+ hasError = true;
429
+ }
430
+ }
431
+ }
432
+
433
+ // @TODO: feature cannot exist in multiple groups
434
+
353
435
  // lint features
354
436
  console.log("\nLinting features...\n");
355
437
  const featureFilePaths = getYAMLFiles(path.join(projectConfig.featuresDirectoryPath));
@@ -375,24 +457,26 @@ export async function lintProject(projectConfig: ProjectConfig): Promise<boolean
375
457
 
376
458
  // lint tests
377
459
  console.log("\nLinting tests...\n");
378
- const testFilePaths = getYAMLFiles(path.join(projectConfig.testsDirectoryPath));
379
- const testsJoiSchema = getTestsJoiSchema(projectConfig);
380
-
381
- for (const filePath of testFilePaths) {
382
- const key = path.basename(filePath, ".yml");
383
- const parsed = parseYaml(fs.readFileSync(filePath, "utf8")) as any;
384
- console.log(" =>", key);
460
+ if (fs.existsSync(projectConfig.testsDirectoryPath)) {
461
+ const testFilePaths = getYAMLFiles(path.join(projectConfig.testsDirectoryPath));
462
+ const testsJoiSchema = getTestsJoiSchema(projectConfig);
463
+
464
+ for (const filePath of testFilePaths) {
465
+ const key = path.basename(filePath, ".yml");
466
+ const parsed = parseYaml(fs.readFileSync(filePath, "utf8")) as any;
467
+ console.log(" =>", key);
468
+
469
+ try {
470
+ await testsJoiSchema.validateAsync(parsed);
471
+ } catch (e) {
472
+ if (e instanceof Joi.ValidationError) {
473
+ printJoiError(e);
474
+ } else {
475
+ console.log(e);
476
+ }
385
477
 
386
- try {
387
- await testsJoiSchema.validateAsync(parsed);
388
- } catch (e) {
389
- if (e instanceof Joi.ValidationError) {
390
- printJoiError(e);
391
- } else {
392
- console.log(e);
478
+ hasError = true;
393
479
  }
394
-
395
- hasError = true;
396
480
  }
397
481
  }
398
482
 
package/src/tester.ts CHANGED
@@ -128,14 +128,9 @@ export function testProject(rootDirectoryPath: string, projectConfig: ProjectCon
128
128
  configureBucketValue: (feature, attributes, bucketValue) => {
129
129
  return currentAt;
130
130
  },
131
- logger: createLogger({
132
- levels: [
133
- // "debug",
134
- // "info",
135
- // "warn",
136
- "error",
137
- ],
138
- }),
131
+ // logger: createLogger({
132
+ // levels: ["debug", "info", "warn", "error"],
133
+ // }),
139
134
  });
140
135
 
141
136
  test.features.forEach(function (feature, fIndex) {