UNPKG

@launchdarkly/js-server-sdk-common

Version:
416 lines 20.1 kB
"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