@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/CHANGELOG.md +19 -0
- package/lib/allocator.d.ts +3 -0
- package/lib/allocator.js +40 -0
- package/lib/allocator.js.map +1 -0
- package/lib/allocator.spec.d.ts +1 -0
- package/lib/allocator.spec.js +85 -0
- package/lib/allocator.spec.js.map +1 -0
- package/lib/builder.js +58 -23
- package/lib/builder.js.map +1 -1
- package/lib/config.d.ts +2 -0
- package/lib/config.js +3 -1
- package/lib/config.js.map +1 -1
- package/lib/linter.d.ts +1 -0
- package/lib/linter.js +122 -41
- package/lib/linter.js.map +1 -1
- package/lib/tester.js +3 -8
- package/lib/tester.js.map +1 -1
- package/lib/traffic.d.ts +8 -2
- package/lib/traffic.js +99 -88
- package/lib/traffic.js.map +1 -1
- package/lib/traffic.spec.js +166 -14
- package/lib/traffic.spec.js.map +1 -1
- package/package.json +5 -5
- package/src/allocator.spec.ts +104 -0
- package/src/allocator.ts +44 -0
- package/src/builder.ts +53 -23
- package/src/config.ts +3 -0
- package/src/linter.ts +100 -16
- package/src/tester.ts +3 -8
- package/src/traffic.spec.ts +167 -16
- package/src/traffic.ts +123 -109
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 {
|
|
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:
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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) {
|