@launchdarkly/js-server-sdk-common
Version:
LaunchDarkly Server SDK for JavaScript - common code
416 lines • 20.1 kB
JavaScript
"use strict";
/* eslint-disable class-methods-use-this */
Object.defineProperty(exports, "__esModule", { value: true });
/* eslint-disable max-classes-per-file */
const js_sdk_common_1 = require("@launchdarkly/js-sdk-common");
const Bucketer_1 = require("./Bucketer");
const collection_1 = require("./collection");
const EvalResult_1 = require("./EvalResult");
const evalTargets_1 = require("./evalTargets");
const makeBigSegmentRef_1 = require("./makeBigSegmentRef");
const matchClause_1 = require("./matchClause");
const matchSegmentTargets_1 = require("./matchSegmentTargets");
const Reasons_1 = require("./Reasons");
const variations_1 = require("./variations");
const { ErrorKinds } = js_sdk_common_1.internal;
const bigSegmentsStatusPriority = {
HEALTHY: 1,
STALE: 2,
STORE_ERROR: 3,
NOT_CONFIGURED: 4,
};
function getBigSegmentsStatusPriority(status) {
if (status !== undefined) {
return bigSegmentsStatusPriority[status] || 0;
}
return 0;
}
/**
* Given two big segment statuses return the one with the higher priority.
* @returns The status with the higher priority.
*/
function computeUpdatedBigSegmentsStatus(old, latest) {
if (old !== undefined &&
getBigSegmentsStatusPriority(old) > getBigSegmentsStatusPriority(latest)) {
return old;
}
return latest;
}
function makeMatch(match) {
return { error: false, isMatch: match, result: undefined };
}
function makeError(result) {
return { error: true, isMatch: false, result };
}
/**
* @internal
*/
class Evaluator {
constructor(platform, queries) {
this._queries = queries;
this._bucketer = new Bucketer_1.default(platform.crypto);
}
async evaluate(flag, context, eventFactory) {
return new Promise((resolve) => {
this.evaluateCb(flag, context, resolve, eventFactory);
});
}
evaluateCb(flag, context, cb, eventFactory) {
const state = {};
this._evaluateInternal(flag, context, state, [], (res) => {
if (state.bigSegmentsStatus) {
res.detail.reason = Object.assign(Object.assign({}, res.detail.reason), { bigSegmentsStatus: state.bigSegmentsStatus });
}
if (state.prerequisites) {
res.prerequisites = state.prerequisites;
}
res.events = state.events;
cb(res);
}, true, eventFactory);
}
/**
* Evaluate the given flag against the given context. This internal method is entered
* initially from the external evaluation method, but may be recursively executed during
* prerequisite evaluations.
* @param flag The flag to evaluate.
* @param context The context to evaluate the flag against.
* @param state The current evaluation state.
* @param visitedFlags The flags that have been visited during this evaluation.
* This is not part of the state, because it needs to be forked during prerequisite evaluations.
* @param topLevel True when this function is being called in the direct evaluation of a flag,
* versus the evaluataion of a prerequisite.
*/
_evaluateInternal(flag, context, state, visitedFlags, cb, topLevel, eventFactory) {
if (!flag.on) {
cb((0, variations_1.getOffVariation)(flag, Reasons_1.default.Off));
return;
}
this._checkPrerequisites(flag, context, state, visitedFlags, (res) => {
// If there is a prereq result, then prereqs have failed, or there was
// an error.
if (res) {
cb(res);
return;
}
const targetRes = (0, evalTargets_1.default)(flag, context);
if (targetRes) {
cb(targetRes);
return;
}
this._evaluateRules(flag, context, state, (evalRes) => {
if (evalRes) {
cb(evalRes);
return;
}
cb(this._variationForContext(flag.fallthrough, context, flag, Reasons_1.default.Fallthrough));
});
}, topLevel, eventFactory);
}
/**
* Evaluate the prerequisite flags for the given flag.
* @param flag The flag to evaluate prerequisites for.
* @param context The context to evaluate the prerequisites against.
* @param state used to accumulate prerequisite events.
* @param visitedFlags Used to detect cycles in prerequisite evaluation.
* @param cb A callback which is executed when prerequisite checks are complete it is called with
* an {@link EvalResult} containing an error result or `undefined` if the prerequisites
* are met.
* @param topLevel True when this function is being called in the direct evaluation of a flag,
* versus the evaluataion of a prerequisite.
*/
_checkPrerequisites(flag, context, state, visitedFlags, cb, topLevel, eventFactory) {
let prereqResult;
if (!flag.prerequisites || !flag.prerequisites.length) {
cb(undefined);
return;
}
// On any error conditions the prereq result will be set, so we do not need
// the result of the series evaluation.
(0, collection_1.allSeriesAsync)(flag.prerequisites, (prereq, _index, iterCb) => {
if (visitedFlags.indexOf(prereq.key) !== -1) {
prereqResult = EvalResult_1.default.forError(ErrorKinds.MalformedFlag, `Prerequisite of ${flag.key} causing a circular reference.` +
' This is probably a temporary condition due to an incomplete update.');
iterCb(true);
return;
}
const updatedVisitedFlags = [...visitedFlags, prereq.key];
this._queries.getFlag(prereq.key, (prereqFlag) => {
if (!prereqFlag) {
prereqResult = (0, variations_1.getOffVariation)(flag, Reasons_1.default.prerequisiteFailed(prereq.key));
iterCb(false);
return;
}
this._evaluateInternal(prereqFlag, context, state, updatedVisitedFlags, (res) => {
var _a, _b;
// eslint-disable-next-line no-param-reassign
(_a = state.events) !== null && _a !== void 0 ? _a : (state.events = []);
if (topLevel) {
// eslint-disable-next-line no-param-reassign
(_b = state.prerequisites) !== null && _b !== void 0 ? _b : (state.prerequisites = []);
state.prerequisites.push(prereqFlag.key);
}
if (eventFactory) {
state.events.push(eventFactory.evalEventServer(prereqFlag, context, res.detail, null, flag));
}
if (res.isError) {
prereqResult = res;
return iterCb(false);
}
if (res.isOff || res.detail.variationIndex !== prereq.variation) {
prereqResult = (0, variations_1.getOffVariation)(flag, Reasons_1.default.prerequisiteFailed(prereq.key));
return iterCb(false);
}
return iterCb(true);
}, false, // topLevel false evaluating the prerequisite.
eventFactory);
});
}, () => {
cb(prereqResult);
});
}
/**
* Evaluate the rules for a flag and return an {@link EvalResult} if there is
* a match or error.
* @param flag The flag to evaluate rules for.
* @param context The context to evaluate the rules against.
* @param state The current evaluation state.
* @param cb Callback called when rule evaluation is complete, it will be called with either
* an {@link EvalResult} or 'undefined'.
*/
_evaluateRules(flag, context, state, cb) {
let ruleResult;
(0, collection_1.firstSeriesAsync)(flag.rules, (rule, ruleIndex, iterCb) => {
this._ruleMatchContext(flag, rule, ruleIndex, context, state, [], (res) => {
ruleResult = res;
iterCb(!!res);
});
}, () => cb(ruleResult));
}
_clauseMatchContext(clause, context, segmentsVisited, state, cb) {
let errorResult;
if (clause.op === 'segmentMatch') {
(0, collection_1.firstSeriesAsync)(clause.values, (value, _index, iterCb) => {
this._queries.getSegment(value, (segment) => {
if (segment) {
if (segmentsVisited.includes(segment.key)) {
errorResult = EvalResult_1.default.forError(ErrorKinds.MalformedFlag, `Segment rule referencing segment ${segment.key} caused a circular reference. ` +
'This is probably a temporary condition due to an incomplete update');
// There was an error, so stop checking further segments.
iterCb(true);
return;
}
const newVisited = [...segmentsVisited, segment === null || segment === void 0 ? void 0 : segment.key];
this.segmentMatchContext(segment, context, state, newVisited, (res) => {
if (res.error) {
errorResult = res.result;
}
iterCb(res.error || res.isMatch);
});
}
else {
iterCb(false);
}
});
}, (match) => {
if (errorResult) {
return cb(makeError(errorResult));
}
return cb(makeMatch((0, matchClause_1.maybeNegate)(clause, match)));
});
return;
}
// This is after segment matching, which does not use the reference.
if (!clause.attributeReference.isValid) {
cb(makeError(EvalResult_1.default.forError(ErrorKinds.MalformedFlag, 'Invalid attribute reference in clause')));
return;
}
cb(makeMatch((0, matchClause_1.default)(clause, context)));
}
/**
* Evaluate a flag rule against the given context.
* @param flag The flag the rule is part of.
* @param rule The rule to match.
* @param rule The index of the rule.
* @param context The context to match the rule against.
* @param cb Called when matching is complete with an {@link EvalResult} or `undefined` if there
* are no matches or errors.
*/
_ruleMatchContext(flag, rule, ruleIndex, context, state, segmentsVisited, cb) {
if (!rule.clauses) {
cb(undefined);
return;
}
let errorResult;
(0, collection_1.allSeriesAsync)(rule.clauses, (clause, _index, iterCb) => {
this._clauseMatchContext(clause, context, segmentsVisited, state, (res) => {
errorResult = res.result;
return iterCb(res.error || res.isMatch);
});
}, (match) => {
if (errorResult) {
return cb(errorResult);
}
if (match) {
return cb(this._variationForContext(rule, context, flag, Reasons_1.default.ruleMatch(rule.id, ruleIndex)));
}
return cb(undefined);
});
}
_variationForContext(varOrRollout, context, flag, reason) {
if (varOrRollout === undefined) {
// By spec this field should be defined, but better to be overly cautious.
return EvalResult_1.default.forError(ErrorKinds.MalformedFlag, 'Fallthrough variation undefined');
}
if (varOrRollout.variation !== undefined) {
// 0 would be false.
return (0, variations_1.getVariation)(flag, varOrRollout.variation, reason);
}
if (varOrRollout.rollout) {
const { rollout } = varOrRollout;
const { variations } = rollout;
const isExperiment = rollout.kind === 'experiment';
if (variations && variations.length) {
const bucketBy = (0, variations_1.getBucketBy)(isExperiment, rollout.bucketByAttributeReference);
if (!bucketBy.isValid) {
return EvalResult_1.default.forError(ErrorKinds.MalformedFlag, 'Invalid attribute reference for bucketBy in rollout');
}
const [bucket, hadContext] = this._bucketer.bucket(context, flag.key, bucketBy, flag.salt || '', rollout.contextKind, rollout.seed);
const updatedReason = Object.assign({}, reason);
let sum = 0;
for (let i = 0; i < variations.length; i += 1) {
const variate = variations[i];
sum += variate.weight / 100000.0;
if (bucket < sum) {
if (isExperiment && hadContext && !variate.untracked) {
updatedReason.inExperiment = true;
}
return (0, variations_1.getVariation)(flag, variate.variation, updatedReason);
}
}
// The context's bucket value was greater than or equal to the end of
// the last bucket. This could happen due to a rounding error, or due to
// the fact that we are scaling to 100000 rather than 99999, or the flag
// data could contain buckets that don't actually add up to 100000.
// Rather than returning an error in this case (or changing the scaling,
// which would potentially change the results for *all* users), we will
// simply put the context in the last bucket.
const lastVariate = variations[variations.length - 1];
if (isExperiment && !lastVariate.untracked) {
updatedReason.inExperiment = true;
}
return (0, variations_1.getVariation)(flag, lastVariate.variation, updatedReason);
}
}
return EvalResult_1.default.forError(ErrorKinds.MalformedFlag, 'Variation/rollout object with no variation or rollout');
}
segmentRuleMatchContext(segment, rule, context, state, segmentsVisited, cb) {
let errorResult;
(0, collection_1.allSeriesAsync)(rule.clauses, (clause, _index, iterCb) => {
this._clauseMatchContext(clause, context, segmentsVisited, state, (res) => {
errorResult = res.result;
iterCb(res.error || res.isMatch);
});
}, (match) => {
if (errorResult) {
return cb(makeError(errorResult));
}
if (match) {
if (rule.weight === undefined) {
return cb(makeMatch(match));
}
const bucketBy = (0, variations_1.getBucketBy)(false, rule.bucketByAttributeReference);
if (!bucketBy.isValid) {
return cb(makeError(EvalResult_1.default.forError(ErrorKinds.MalformedFlag, 'Invalid attribute reference in clause')));
}
const [bucket] = this._bucketer.bucket(context, segment.key, bucketBy, segment.salt || '', rule.rolloutContextKind);
return cb(makeMatch(bucket < rule.weight / 100000.0));
}
return cb(makeMatch(false));
});
}
// eslint-disable-next-line class-methods-use-this
simpleSegmentMatchContext(segment, context, state, segmentsVisited, cb) {
if (!segment.unbounded) {
const includeExclude = (0, matchSegmentTargets_1.default)(segment, context);
if (includeExclude !== undefined) {
cb(makeMatch(includeExclude));
return;
}
}
let evalResult;
(0, collection_1.firstSeriesAsync)(segment.rules, (rule, _index, iterCb) => {
this.segmentRuleMatchContext(segment, rule, context, state, segmentsVisited, (res) => {
evalResult = res.result;
return iterCb(res.error || res.isMatch);
});
}, (matched) => {
if (evalResult) {
return cb(makeError(evalResult));
}
return cb(makeMatch(matched));
});
}
segmentMatchContext(segment, context, state, segmentsVisited, cb) {
if (!segment.unbounded) {
this.simpleSegmentMatchContext(segment, context, state, segmentsVisited, cb);
return;
}
const bigSegmentKind = segment.unboundedContextKind || 'user';
const keyForBigSegment = context.key(bigSegmentKind);
if (!keyForBigSegment) {
cb(makeMatch(false));
return;
}
if (!segment.generation) {
// Big Segment queries can only be done if the generation is known. If it's unset,
// that probably means the data store was populated by an older SDK that doesn't know
// about the generation property and therefore dropped it from the JSON data. We'll treat
// that as a "not configured" condition.
// eslint-disable-next-line no-param-reassign
state.bigSegmentsStatus = computeUpdatedBigSegmentsStatus(state.bigSegmentsStatus, 'NOT_CONFIGURED');
cb(makeMatch(false));
return;
}
if (state.bigSegmentsMembership && state.bigSegmentsMembership[keyForBigSegment]) {
// We've already done the query at some point during the flag evaluation and stored
// the result (if any) in stateOut.bigSegmentsMembership, so we don't need to do it
// again. Even if multiple Big Segments are being referenced, the membership includes
// *all* of the user's segment memberships.
this.bigSegmentMatchContext(state.bigSegmentsMembership[keyForBigSegment], segment, context, state).then(cb);
return;
}
this._queries.getBigSegmentsMembership(keyForBigSegment).then((result) => {
// eslint-disable-next-line no-param-reassign
state.bigSegmentsMembership = state.bigSegmentsMembership || {};
if (result) {
const [membership, status] = result;
// eslint-disable-next-line no-param-reassign
state.bigSegmentsMembership[keyForBigSegment] = membership;
// eslint-disable-next-line no-param-reassign
state.bigSegmentsStatus = computeUpdatedBigSegmentsStatus(state.bigSegmentsStatus, status);
}
else {
// eslint-disable-next-line no-param-reassign
state.bigSegmentsStatus = computeUpdatedBigSegmentsStatus(state.bigSegmentsStatus, 'NOT_CONFIGURED');
}
/* eslint-enable no-param-reassign */
this.bigSegmentMatchContext(state.bigSegmentsMembership[keyForBigSegment], segment, context, state).then(cb);
});
}
bigSegmentMatchContext(membership, segment, context, state) {
const segmentRef = (0, makeBigSegmentRef_1.default)(segment);
const included = membership === null || membership === void 0 ? void 0 : membership[segmentRef];
return new Promise((resolve) => {
// Typically null is not checked because we filter it from the data
// we get in flag updates. Here it is checked because big segment data
// will be contingent on the store that implements it.
if (included !== undefined && included !== null) {
resolve(makeMatch(included));
return;
}
this.simpleSegmentMatchContext(segment, context, state, [], resolve);
});
}
}
exports.default = Evaluator;
//# sourceMappingURL=Evaluator.js.map