@featurevisor/core 0.37.1 → 0.38.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
@@ -57,8 +57,16 @@ export function getExistingStateFilePath(
57
57
  return path.join(projectConfig.stateDirectoryPath, `existing-state-${environment}.json`);
58
58
  }
59
59
 
60
- export function getFeatureRanges(projectConfig: ProjectConfig): Map<FeatureKey, Range[]> {
60
+ export type FeatureRanges = Map<FeatureKey, Range[]>;
61
+
62
+ interface FeatureRangesResult {
63
+ featureRanges: FeatureRanges;
64
+ featureIsInGroup: { [key: string]: boolean };
65
+ }
66
+
67
+ export function getFeatureRanges(projectConfig: ProjectConfig): FeatureRangesResult {
61
68
  const featureRanges = new Map<FeatureKey, Range[]>();
69
+ const featureIsInGroup = {}; // featureKey => boolean
62
70
 
63
71
  const groups: Group[] = [];
64
72
  if (fs.existsSync(projectConfig.groupsDirectoryPath)) {
@@ -82,6 +90,11 @@ export function getFeatureRanges(projectConfig: ProjectConfig): Map<FeatureKey,
82
90
 
83
91
  if (slot.feature) {
84
92
  const featureKey = slot.feature;
93
+
94
+ if (typeof featureKey === "string") {
95
+ featureIsInGroup[featureKey] = true;
96
+ }
97
+
85
98
  const featureRangesForFeature = featureRanges.get(featureKey) || [];
86
99
 
87
100
  const start = isFirstSlot ? accumulatedPercentage : accumulatedPercentage + 1;
@@ -97,7 +110,7 @@ export function getFeatureRanges(projectConfig: ProjectConfig): Map<FeatureKey,
97
110
  }
98
111
  }
99
112
 
100
- return featureRanges;
113
+ return { featureRanges, featureIsInGroup };
101
114
  }
102
115
 
103
116
  export function buildDatafile(
@@ -115,7 +128,7 @@ export function buildDatafile(
115
128
 
116
129
  const segmentKeysUsedByTag = new Set<SegmentKey>();
117
130
  const attributeKeysUsedByTag = new Set<AttributeKey>();
118
- const featureRanges = getFeatureRanges(projectConfig);
131
+ const { featureRanges, featureIsInGroup } = getFeatureRanges(projectConfig);
119
132
 
120
133
  // features
121
134
  const features: Feature[] = [];
@@ -148,79 +161,85 @@ export function buildDatafile(
148
161
 
149
162
  const feature: Feature = {
150
163
  key: featureKey,
151
- defaultVariation: parsedFeature.defaultVariation,
152
164
  bucketBy: parsedFeature.bucketBy || projectConfig.defaultBucketBy,
153
- variations: parsedFeature.variations.map((variation: Variation) => {
154
- const mappedVariation: any = {
155
- value: variation.value,
156
- 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
157
- };
158
-
159
- if (!variation.variables) {
160
- return mappedVariation;
161
- }
165
+ variations: Array.isArray(parsedFeature.variations)
166
+ ? parsedFeature.variations.map((variation: Variation) => {
167
+ const mappedVariation: any = {
168
+ value: variation.value,
169
+ 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
170
+ };
162
171
 
163
- mappedVariation.variables = variation.variables.map((variable: Variable) => {
164
- const mappedVariable: any = {
165
- key: variable.key,
166
- value: variable.value,
167
- };
168
-
169
- if (!variable.overrides) {
170
- return mappedVariable;
171
- }
172
-
173
- mappedVariable.overrides = variable.overrides.map((override: VariableOverride) => {
174
- if (typeof override.conditions !== "undefined") {
175
- const extractedAttributeKeys = extractAttributeKeysFromConditions(
176
- override.conditions,
177
- );
178
- extractedAttributeKeys.forEach((attributeKey) =>
179
- attributeKeysUsedByTag.add(attributeKey),
180
- );
181
-
182
- return {
183
- conditions: JSON.stringify(override.conditions),
184
- value: override.value,
185
- };
172
+ if (!variation.variables) {
173
+ return mappedVariation;
186
174
  }
187
175
 
188
- if (typeof override.segments !== "undefined") {
189
- const extractedSegmentKeys = extractSegmentKeysFromGroupSegments(
190
- override.segments as GroupSegment | GroupSegment[],
191
- );
192
- extractedSegmentKeys.forEach((segmentKey) => segmentKeysUsedByTag.add(segmentKey));
193
-
194
- return {
195
- segments: JSON.stringify(override.segments),
196
- value: override.value,
176
+ mappedVariation.variables = variation.variables.map((variable: Variable) => {
177
+ const mappedVariable: any = {
178
+ key: variable.key,
179
+ value: variable.value,
197
180
  };
198
- }
199
-
200
- return override;
201
- });
202
-
203
- return mappedVariable;
204
- });
205
181
 
206
- return mappedVariation;
207
- }),
182
+ if (!variable.overrides) {
183
+ return mappedVariable;
184
+ }
185
+
186
+ mappedVariable.overrides = variable.overrides.map((override: VariableOverride) => {
187
+ if (typeof override.conditions !== "undefined") {
188
+ const extractedAttributeKeys = extractAttributeKeysFromConditions(
189
+ override.conditions,
190
+ );
191
+ extractedAttributeKeys.forEach((attributeKey) =>
192
+ attributeKeysUsedByTag.add(attributeKey),
193
+ );
194
+
195
+ return {
196
+ conditions: JSON.stringify(override.conditions),
197
+ value: override.value,
198
+ };
199
+ }
200
+
201
+ if (typeof override.segments !== "undefined") {
202
+ const extractedSegmentKeys = extractSegmentKeysFromGroupSegments(
203
+ override.segments as GroupSegment | GroupSegment[],
204
+ );
205
+ extractedSegmentKeys.forEach((segmentKey) =>
206
+ segmentKeysUsedByTag.add(segmentKey),
207
+ );
208
+
209
+ return {
210
+ segments: JSON.stringify(override.segments),
211
+ value: override.value,
212
+ };
213
+ }
214
+
215
+ return override;
216
+ });
217
+
218
+ return mappedVariable;
219
+ });
220
+
221
+ return mappedVariation;
222
+ })
223
+ : undefined,
208
224
  traffic: getTraffic(
209
225
  parsedFeature.variations,
210
226
  parsedFeature.environments[options.environment].rules,
211
227
  existingState.features[featureKey],
212
228
  featureRanges.get(featureKey) || [],
213
229
  ),
230
+ ranges: featureRanges.get(featureKey) || undefined,
214
231
  };
215
232
 
216
233
  // update state in memory, so that next datafile build can use it (in case it contains the same feature)
217
234
  existingState.features[featureKey] = {
218
- variations: feature.variations.map((v: Variation) => {
219
- return {
220
- value: v.value,
221
- weight: v.weight || 0,
222
- };
223
- }),
235
+ variations: Array.isArray(feature.variations)
236
+ ? feature.variations.map((v: Variation) => {
237
+ return {
238
+ value: v.value,
239
+ weight: v.weight || 0,
240
+ };
241
+ })
242
+ : undefined,
224
243
  traffic: feature.traffic.map((t: Traffic) => {
225
244
  return {
226
245
  key: t.key,
@@ -233,9 +252,12 @@ export function buildDatafile(
233
252
  }),
234
253
  };
235
254
  }),
236
- ranges: featureRanges.get(feature.key) || undefined,
237
255
  };
238
256
 
257
+ if (featureIsInGroup[featureKey] === true) {
258
+ feature.ranges = featureRanges.get(feature.key);
259
+ }
260
+
239
261
  if (parsedFeature.variablesSchema) {
240
262
  feature.variablesSchema = parsedFeature.variablesSchema;
241
263
  }
@@ -138,7 +138,7 @@ ${attributeProperties}
138
138
  const featureKey = path.basename(featureFile, ".yml");
139
139
  const parsedFeature = parseYaml(fs.readFileSync(featureFile, "utf8")) as ParsedFeature;
140
140
 
141
- const variationType = getFeaturevisorTypeFromValue(parsedFeature.defaultVariation);
141
+ const variationType = "string";
142
142
  const variationTypeScriptType = convertFeaturevisorTypeToTypeScriptType(variationType);
143
143
 
144
144
  if (typeof parsedFeature.archived !== "undefined" && parsedFeature.archived) {
@@ -182,8 +182,12 @@ import { getInstance } from "./instance";
182
182
  export namespace ${namespaceValue} {
183
183
  export const key = "${featureKey}";
184
184
 
185
+ export function isEnabled(context: Context = {}) {
186
+ return getInstance().isEnabled(key, context);
187
+ }
188
+
185
189
  export function getVariation(context: Context = {}) {
186
- return getInstance().getVariation${getPascalCase(variationType)}(key, context);
190
+ return getInstance().getVariation(key, context);
187
191
  }${variableMethods}
188
192
  }
189
193
  `.trimStart();
package/src/linter.ts CHANGED
@@ -173,9 +173,10 @@ export function getFeatureJoiSchema(
173
173
  conditionsJoiSchema,
174
174
  availableSegmentKeys: string[],
175
175
  ) {
176
- const variationValueJoiSchema = Joi.alternatives().try(Joi.string(), Joi.number(), Joi.boolean());
176
+ const variationValueJoiSchema = Joi.string().required();
177
177
  const variableValueJoiSchema = Joi.alternatives()
178
178
  .try(
179
+ // @TODO: make it stricter based on variableSchema.type
179
180
  Joi.string(),
180
181
  Joi.number(),
181
182
  Joi.boolean(),
@@ -230,7 +231,9 @@ export function getFeatureJoiSchema(
230
231
  key: Joi.string(),
231
232
  segments: groupSegmentsJoiSchema,
232
233
  percentage: Joi.number().precision(3).min(0).max(100),
233
- variation: variationValueJoiSchema.optional(),
234
+
235
+ enabled: Joi.boolean().optional(),
236
+ variation: variationValueJoiSchema.optional(), // @TODO: only allowed if feature.variations is present
234
237
  variables: Joi.object().optional(), // @TODO: make it stricter
235
238
  }),
236
239
  )
@@ -242,7 +245,8 @@ export function getFeatureJoiSchema(
242
245
  segments: groupSegmentsJoiSchema.optional(),
243
246
  conditions: conditionsJoiSchema.optional(),
244
247
 
245
- variation: variationValueJoiSchema,
248
+ enabled: Joi.boolean().optional(),
249
+ variation: variationValueJoiSchema.optional(),
246
250
  variables: Joi.object().optional(), // @TODO: make it stricter
247
251
  }),
248
252
  ),
@@ -269,8 +273,6 @@ export function getFeatureJoiSchema(
269
273
  )
270
274
  .required(),
271
275
 
272
- defaultVariation: variationValueJoiSchema,
273
-
274
276
  bucketBy: Joi.alternatives()
275
277
  .try(
276
278
  // plain
@@ -331,23 +333,15 @@ export function getFeatureJoiSchema(
331
333
  }),
332
334
  )
333
335
  .custom((value, helpers) => {
334
- var total = value.reduce((a, b) => a + b.weight, 0);
336
+ var total = value.reduce((acc, v) => acc + v.weight, 0);
335
337
 
336
338
  if (total !== 100) {
337
339
  throw new Error(`Sum of all variation weights must be 100, got ${total}`);
338
340
  }
339
341
 
340
- const typeOf = new Set(value.map((v) => typeof v.value));
341
-
342
- if (typeOf.size > 1) {
343
- throw new Error(
344
- `All variations must have the same type, got ${Array.from(typeOf).join(", ")}`,
345
- );
346
- }
347
-
348
342
  return value;
349
343
  })
350
- .required(),
344
+ .optional(),
351
345
 
352
346
  environments: allEnvironmentsJoiSchema.required(),
353
347
  });
@@ -382,7 +376,8 @@ export function getTestsJoiSchema(
382
376
  at: Joi.number().precision(3).min(0).max(100),
383
377
  context: Joi.object(),
384
378
 
385
- // @TODO: one or both below
379
+ // @TODO: one or all below
380
+ expectedToBeEnabled: Joi.boolean().required(),
386
381
  expectedVariation: Joi.alternatives().try(
387
382
  Joi.string(),
388
383
  Joi.number(),
package/src/site.ts CHANGED
@@ -310,37 +310,39 @@ export function generateSiteSearchIndex(
310
310
  const fileContent = fs.readFileSync(filePath, "utf8");
311
311
  const parsed = parseYaml(fileContent) as ParsedFeature;
312
312
 
313
- parsed.variations.forEach((variation) => {
314
- if (!variation.variables) {
315
- return;
316
- }
313
+ if (Array.isArray(parsed.variations)) {
314
+ parsed.variations.forEach((variation) => {
315
+ if (!variation.variables) {
316
+ return;
317
+ }
317
318
 
318
- variation.variables.forEach((v) => {
319
- if (v.overrides) {
320
- v.overrides.forEach((o) => {
321
- if (o.conditions) {
322
- extractAttributeKeysFromConditions(o.conditions).forEach((attributeKey) => {
323
- if (!attributesUsedInFeatures[attributeKey]) {
324
- attributesUsedInFeatures[attributeKey] = new Set();
325
- }
326
-
327
- attributesUsedInFeatures[attributeKey].add(entityName);
328
- });
329
- }
319
+ variation.variables.forEach((v) => {
320
+ if (v.overrides) {
321
+ v.overrides.forEach((o) => {
322
+ if (o.conditions) {
323
+ extractAttributeKeysFromConditions(o.conditions).forEach((attributeKey) => {
324
+ if (!attributesUsedInFeatures[attributeKey]) {
325
+ attributesUsedInFeatures[attributeKey] = new Set();
326
+ }
327
+
328
+ attributesUsedInFeatures[attributeKey].add(entityName);
329
+ });
330
+ }
330
331
 
331
- if (o.segments && o.segments !== "*") {
332
- extractSegmentKeysFromGroupSegments(o.segments).forEach((segmentKey) => {
333
- if (!segmentsUsedInFeatures[segmentKey]) {
334
- segmentsUsedInFeatures[segmentKey] = new Set();
335
- }
332
+ if (o.segments && o.segments !== "*") {
333
+ extractSegmentKeysFromGroupSegments(o.segments).forEach((segmentKey) => {
334
+ if (!segmentsUsedInFeatures[segmentKey]) {
335
+ segmentsUsedInFeatures[segmentKey] = new Set();
336
+ }
336
337
 
337
- segmentsUsedInFeatures[segmentKey].add(entityName);
338
- });
339
- }
340
- });
341
- }
338
+ segmentsUsedInFeatures[segmentKey].add(entityName);
339
+ });
340
+ }
341
+ });
342
+ }
343
+ });
342
344
  });
343
- });
345
+ }
344
346
 
345
347
  Object.keys(parsed.environments).forEach((environmentKey) => {
346
348
  const env = parsed.environments[environmentKey];
package/src/tester.ts CHANGED
@@ -118,7 +118,7 @@ export function testProject(rootDirectoryPath: string, projectConfig: ProjectCon
118
118
  }
119
119
  });
120
120
  });
121
- } else {
121
+ } else if (test.environment && test.tag && test.features) {
122
122
  // feature testing
123
123
  const datafilePath = getDatafilePath(projectConfig, test.environment, test.tag);
124
124
 
@@ -158,6 +158,20 @@ export function testProject(rootDirectoryPath: string, projectConfig: ProjectCon
158
158
  let assertionHasError = false;
159
159
  currentAt = assertion.at * (MAX_BUCKETED_NUMBER / 100);
160
160
 
161
+ // isEnabled
162
+ if ("expectedToBeEnabled" in assertion) {
163
+ const isEnabled = sdk.isEnabled(featureKey, assertion.context);
164
+
165
+ if (isEnabled !== assertion.expectedToBeEnabled) {
166
+ hasError = true;
167
+ assertionHasError = true;
168
+
169
+ console.error(
170
+ ` isEnabled failed: expected "${assertion.expectedToBeEnabled}", got "${isEnabled}"`,
171
+ );
172
+ }
173
+ }
174
+
161
175
  // variation
162
176
  if ("expectedVariation" in assertion) {
163
177
  const variation = sdk.getVariation(featureKey, assertion.context);
@@ -203,6 +217,9 @@ export function testProject(rootDirectoryPath: string, projectConfig: ProjectCon
203
217
  }
204
218
  });
205
219
  });
220
+ } else {
221
+ console.error(` => Invalid test: ${JSON.stringify(test)}`);
222
+ hasError = true;
206
223
  }
207
224
  });
208
225
  }
package/src/traffic.ts CHANGED
@@ -4,20 +4,24 @@ import { MAX_BUCKETED_NUMBER } from "@featurevisor/sdk";
4
4
  import { getAllocation, getUpdatedAvailableRangesAfterFilling } from "./allocator";
5
5
 
6
6
  export function detectIfVariationsChanged(
7
- yamlVariations: Variation[], // as exists in latest YAML
7
+ yamlVariations: Variation[] | undefined, // as exists in latest YAML
8
8
  existingFeature?: ExistingFeature, // from state file
9
9
  ): boolean {
10
- if (!existingFeature) {
10
+ if (!existingFeature || typeof existingFeature.variations === "undefined") {
11
11
  return false;
12
12
  }
13
13
 
14
+ const checkVariations = Array.isArray(yamlVariations)
15
+ ? JSON.stringify(yamlVariations.map(({ value, weight }) => ({ value, weight })))
16
+ : undefined;
17
+
14
18
  return (
15
19
  JSON.stringify(
16
20
  existingFeature.variations.map(({ value, weight }) => ({
17
21
  value,
18
22
  weight,
19
23
  })),
20
- ) !== JSON.stringify(yamlVariations.map(({ value, weight }) => ({ value, weight })))
24
+ ) !== checkVariations
21
25
  );
22
26
  }
23
27
 
@@ -51,7 +55,7 @@ export function detectIfRangesChanged(
51
55
 
52
56
  export function getTraffic(
53
57
  // from current YAML
54
- variations: Variation[],
58
+ variations: Variation[] | undefined,
55
59
  parsedRules: Rule[],
56
60
  // from previous release
57
61
  existingFeature: ExistingFeature | undefined,
@@ -118,27 +122,29 @@ export function getTraffic(
118
122
  updatedAvailableRanges = getUpdatedAvailableRangesAfterFilling(availableRanges, existingSum);
119
123
  }
120
124
 
121
- variations.forEach(function (variation) {
122
- const weight = variation.weight as number;
123
- const percentage = weight * (MAX_BUCKETED_NUMBER / 100);
124
-
125
- let toFillValue = needsRebucketing
126
- ? percentage * (rulePercentage / 100) // whole value
127
- : (weight / 100) * rulePercentageDiff; // incrementing
128
- const rangesToFill = getAllocation(updatedAvailableRanges, toFillValue);
129
-
130
- rangesToFill.forEach(function (range) {
131
- traffic.allocation.push({
132
- variation: variation.value,
133
- range,
125
+ if (Array.isArray(variations)) {
126
+ variations.forEach(function (variation) {
127
+ const weight = variation.weight as number;
128
+ const percentage = weight * (MAX_BUCKETED_NUMBER / 100);
129
+
130
+ let toFillValue = needsRebucketing
131
+ ? percentage * (rulePercentage / 100) // whole value
132
+ : (weight / 100) * rulePercentageDiff; // incrementing
133
+ const rangesToFill = getAllocation(updatedAvailableRanges, toFillValue);
134
+
135
+ rangesToFill.forEach(function (range) {
136
+ traffic.allocation.push({
137
+ variation: variation.value,
138
+ range,
139
+ });
134
140
  });
135
- });
136
141
 
137
- updatedAvailableRanges = getUpdatedAvailableRangesAfterFilling(
138
- updatedAvailableRanges,
139
- toFillValue,
140
- );
141
- });
142
+ updatedAvailableRanges = getUpdatedAvailableRangesAfterFilling(
143
+ updatedAvailableRanges,
144
+ toFillValue,
145
+ );
146
+ });
147
+ }
142
148
 
143
149
  traffic.allocation = traffic.allocation.filter((a) => {
144
150
  if (a.range && a.range[0] === a.range[1]) {