Merge pull request #4346 from overleaf/ab-configurable-split-test-2

Configurable Split Tests

GitOrigin-RevId: e648a77848ddb8b8b55a95887f87cf7cdd300ee9
This commit is contained in:
Alexandre Bourdin 2021-07-28 10:51:29 +02:00 committed by Copybot
parent 9468e5cb4f
commit 51546b29c4
7 changed files with 797 additions and 3 deletions

View file

@ -0,0 +1,25 @@
const SplitTestManager = require('./SplitTestManager')
const { SplitTest } = require('../../models/SplitTest')
const { CacheLoader } = require('cache-flow')
class SplitTestCache extends CacheLoader {
constructor() {
super('split-test', {
expirationTime: 60, // 1min in seconds
})
}
async load(name) {
return await SplitTestManager.getSplitTestByName(name)
}
serialize(value) {
return value.toObject()
}
deserialize(value) {
return new SplitTest(value)
}
}
module.exports = new SplitTestCache()

View file

@ -114,8 +114,14 @@ function _getPercentile(userId, splitTestId) {
} }
module.exports = { module.exports = {
/**
* @deprecated: use SplitTestV2Handler.getAssignment instead
*/
getTestSegmentation: callbackify(getTestSegmentation), getTestSegmentation: callbackify(getTestSegmentation),
promises: { promises: {
/**
* @deprecated: use SplitTestV2Handler.promises.getAssignment instead
*/
getTestSegmentation, getTestSegmentation,
}, },
} }

View file

@ -0,0 +1,230 @@
const { SplitTest } = require('../../models/SplitTest')
const OError = require('@overleaf/o-error')
const _ = require('lodash')
const ALPHA_PHASE = 'alpha'
const BETA_PHASE = 'beta'
const RELEASE_PHASE = 'release'
async function getSplitTests() {
try {
return await SplitTest.find().exec()
} catch (error) {
throw OError.tag(error, 'Failed to get split tests list')
}
}
async function getSplitTestByName(name) {
try {
return await SplitTest.findOne({ name }).exec()
} catch (error) {
throw OError.tag(error, 'Failed to get split test', { name })
}
}
async function createSplitTest(name, configuration) {
const stripedVariants = []
let stripeStart = 0
_checkNewVariantsConfiguration([], configuration.variants)
for (const variant of configuration.variants) {
stripedVariants.push({
name: variant.name,
active: variant.active,
rolloutPercent: variant.rolloutPercent,
rolloutStripes: [
{
start: stripeStart,
end: stripeStart + variant.rolloutPercent,
},
],
})
stripeStart += variant.rolloutPercent
}
const splitTest = new SplitTest({
name,
versions: [
{
versionNumber: 1,
phase: configuration.phase,
active: configuration.active,
variants: stripedVariants,
},
],
})
return _saveSplitTest(splitTest)
}
async function updateSplitTest(name, configuration) {
const splitTest = await getSplitTestByName(name)
if (splitTest) {
const lastVersion = splitTest.getCurrentVersion().toObject()
if (configuration.phase !== lastVersion.phase) {
throw new OError(
`Cannot update with different phase - use switchToNextPhase endpoint instead`
)
}
_checkNewVariantsConfiguration(lastVersion.variants, configuration.variants)
const updatedVariants = _updateVariantsWithNewConfiguration(
lastVersion.variants,
configuration.variants
)
splitTest.versions.push({
versionNumber: lastVersion.versionNumber + 1,
phase: configuration.phase,
active: configuration.active,
variants: updatedVariants,
})
return _saveSplitTest(splitTest)
} else {
throw new OError(`Cannot update split test '${name}': not found`)
}
}
async function switchToNextPhase(name) {
const splitTest = await getSplitTestByName(name)
if (splitTest) {
const lastVersionCopy = splitTest.getCurrentVersion().toObject()
lastVersionCopy.versionNumber++
if (lastVersionCopy.phase === ALPHA_PHASE) {
lastVersionCopy.phase = BETA_PHASE
} else if (lastVersionCopy.phase === BETA_PHASE) {
if (splitTest.forbidReleasePhase) {
throw new OError('Switch to release phase is disabled for this test')
}
lastVersionCopy.phase = RELEASE_PHASE
} else if (splitTest.phase === RELEASE_PHASE) {
throw new OError(
`Split test with ID '${name}' is already in the release phase`
)
}
for (const variant of lastVersionCopy.variants) {
variant.rolloutPercent = 0
variant.rolloutStripes = []
}
splitTest.versions.push(lastVersionCopy)
return _saveSplitTest(splitTest)
} else {
throw new OError(
`Cannot switch split test with ID '${name}' to next phase: not found`
)
}
}
async function revertToPreviousVersion(name, versionNumber) {
const splitTest = await getSplitTestByName(name)
if (splitTest) {
if (splitTest.versions.length <= 1) {
throw new OError(
`Cannot revert split test with ID '${name}' to previous version: split test must have at least 2 versions`
)
}
const previousVersion = splitTest.getVersion(versionNumber)
if (!previousVersion) {
throw new OError(
`Cannot revert split test with ID '${name}' to version number ${versionNumber}: version not found`
)
}
const lastVersion = splitTest.getCurrentVersion()
if (
lastVersion.phase === RELEASE_PHASE &&
previousVersion.phase !== RELEASE_PHASE
) {
splitTest.forbidReleasePhase = true
}
const previousVersionCopy = previousVersion.toObject()
previousVersionCopy.versionNumber = lastVersion.versionNumber + 1
splitTest.versions.push(previousVersionCopy)
return _saveSplitTest(splitTest)
} else {
throw new OError(
`Cannot revert split test with ID '${name}' to previous version: not found`
)
}
}
function _checkNewVariantsConfiguration(variants, newVariantsConfiguration) {
const totalRolloutPercentage = _getTotalRolloutPercentage(
newVariantsConfiguration
)
if (totalRolloutPercentage > 100) {
throw new OError(`Total variants rollout percentage cannot exceed 100`)
}
for (const variant of variants) {
const newVariantConfiguration = _.find(newVariantsConfiguration, {
name: variant.name,
})
if (!newVariantConfiguration) {
throw new OError(
`Variant defined in previous version as ${JSON.stringify(
variant
)} cannot be removed in new configuration: either set it inactive or create a new split test`
)
}
if (newVariantConfiguration.rolloutPercent < variant.rolloutPercent) {
throw new OError(
`Rollout percentage for variant defined in previous version as ${JSON.stringify(
variant
)} cannot be decreased: revert to a previous configuration instead`
)
}
}
}
function _updateVariantsWithNewConfiguration(
variants,
newVariantsConfiguration
) {
let totalRolloutPercentage = _getTotalRolloutPercentage(variants)
const variantsCopy = _.clone(variants)
for (const newVariantConfig of newVariantsConfiguration) {
const variant = _.find(variantsCopy, { name: newVariantConfig.name })
if (!variant) {
variantsCopy.push({
name: newVariantConfig.name,
active: newVariantConfig.active,
rolloutPercent: newVariantConfig.rolloutPercent,
rolloutStripes: [
{
start: totalRolloutPercentage,
end: totalRolloutPercentage + newVariantConfig.rolloutPercent,
},
],
})
totalRolloutPercentage += newVariantConfig.rolloutPercent
} else if (variant.rolloutPercent < newVariantConfig.rolloutPercent) {
const newStripeSize =
newVariantConfig.rolloutPercent - variant.rolloutPercent
variant.active = newVariantConfig.active
variant.rolloutPercent = newVariantConfig.rolloutPercent
variant.rolloutStripes.push({
start: totalRolloutPercentage,
end: totalRolloutPercentage + newStripeSize,
})
totalRolloutPercentage += newStripeSize
}
}
return variantsCopy
}
function _getTotalRolloutPercentage(variants) {
return _.sumBy(variants, 'rolloutPercent')
}
async function _saveSplitTest(splitTest) {
try {
return (await splitTest.save()).toObject()
} catch (error) {
throw OError.tag(error, 'Failed to save split test', {
splitTest: JSON.stringify(splitTest),
})
}
}
module.exports = {
getSplitTestByName,
getSplitTests,
createSplitTest,
updateSplitTest,
switchToNextPhase,
revertToPreviousVersion,
}

View file

@ -0,0 +1,177 @@
const UserGetter = require('../User/UserGetter')
const UserUpdater = require('../User/UserUpdater')
const AnalyticsManager = require('../Analytics/AnalyticsManager')
const crypto = require('crypto')
const _ = require('lodash')
const { callbackify } = require('util')
const splitTestCache = require('./SplitTestCache')
const DEFAULT_VARIANT = 'default'
const ALPHA_PHASE = 'alpha'
const BETA_PHASE = 'beta'
/**
* Get the assignment of a user to a split test.
*
* @example
* // Assign user and record an event
*
* const assignment = await SplitTestV2Handler.getAssignment(userId, 'example-project')
* if (assignment.variant === 'awesome-new-version') {
* // execute my awesome change
* }
* else {
* // execute the default behaviour (control group)
* }
* // then record an event
* AnalyticsManager.recordEvent(userId, 'example-project-created', {
* projectId: project._id,
* ...assignment.analytics.segmentation
* })
*
* @param userId the user's ID
* @param splitTestName the unique name of the split test
* @param options {sync: boolean} - for test purposes only, to force the synchronous update of the user's profile
* @returns {Promise<{analytics: {segmentation: {}}, variant: string}|{analytics: {segmentation: {phase, splitTest, variant: string, versionNumber}}, variant: string}>}
*/
async function getAssignment(userId, splitTestName, options) {
const splitTest = await splitTestCache.get(splitTestName)
if (splitTest) {
const currentVersion = splitTest.getCurrentVersion()
if (currentVersion.active) {
const {
activeForUser,
selectedVariantName,
phase,
versionNumber,
} = await _getAssignmentMetadata(userId, splitTest)
if (activeForUser) {
const assignmentConfig = {
userId,
splitTestName,
variantName: selectedVariantName,
phase,
versionNumber,
}
if (options && options.sync === true) {
await _updateVariantAssignment(assignmentConfig)
} else {
_updateVariantAssignment(assignmentConfig)
}
return {
variant: selectedVariantName,
analytics: {
segmentation: {
splitTest: splitTestName,
variant: selectedVariantName,
phase,
versionNumber,
},
},
}
}
}
}
return {
variant: DEFAULT_VARIANT,
analytics: {
segmentation: {},
},
}
}
async function _getAssignmentMetadata(userId, splitTest) {
const currentVersion = splitTest.getCurrentVersion()
const phase = currentVersion.phase
if ([ALPHA_PHASE, BETA_PHASE].includes(phase)) {
const user = await _getUser(userId)
if (
(phase === ALPHA_PHASE && !(user && user.alphaProgram)) ||
(phase === BETA_PHASE && !(user && user.betaProgram))
) {
return {
activeForUser: false,
}
}
}
const percentile = _getPercentile(userId, splitTest.name, phase)
const selectedVariantName = _getVariantFromPercentile(
currentVersion.variants,
percentile
)
return {
activeForUser: true,
selectedVariantName: selectedVariantName || DEFAULT_VARIANT,
phase,
versionNumber: currentVersion.versionNumber,
}
}
function _getPercentile(userId, splitTestName, splitTestPhase) {
const hash = crypto
.createHash('md5')
.update(userId + splitTestName + splitTestPhase)
.digest('hex')
const hashPrefix = hash.substr(0, 8)
return Math.floor(
((parseInt(hashPrefix, 16) % 0xffffffff) / 0xffffffff) * 100
)
}
function _getVariantFromPercentile(variants, percentile) {
for (const variant of variants) {
for (const stripe of variant.rolloutStripes) {
if (percentile >= stripe.start && percentile < stripe.end) {
return variant.name
}
}
}
}
async function _updateVariantAssignment({
userId,
splitTestName,
phase,
versionNumber,
variantName,
}) {
const user = await _getUser(userId)
if (user) {
const assignedSplitTests = user.splitTests || []
const assignmentLog = assignedSplitTests[splitTestName] || []
const existingAssignment = _.find(assignmentLog, { versionNumber })
if (!existingAssignment) {
await UserUpdater.promises.updateUser(userId, {
$addToSet: {
[`splitTests.${splitTestName}`]: {
variantName,
versionNumber,
phase,
assignedAt: new Date(),
},
},
})
AnalyticsManager.setUserProperty(
userId,
`split-test-${splitTestName}-${versionNumber}`,
variantName
)
}
}
}
async function _getUser(id) {
return UserGetter.promises.getUser(id, {
splitTests: 1,
alphaProgram: 1,
betaProgram: 1,
})
}
module.exports = {
getAssignment: callbackify(getAssignment),
promises: {
getAssignment,
},
}

View file

@ -0,0 +1,111 @@
const mongoose = require('../infrastructure/Mongoose')
const { Schema } = mongoose
const _ = require('lodash')
const MIN_NAME_LENGTH = 3
const MAX_NAME_LENGTH = 200
const MIN_VARIANT_NAME_LENGTH = 3
const MAX_VARIANT_NAME_LENGTH = 255
const NAME_REGEX = /^[a-zA-Z0-9\-_]+$/
const RolloutPercentType = {
type: Number,
default: 0,
min: [0, 'Rollout percentage must be between 0 and 100, got {VALUE}'],
max: [100, 'Rollout percentage must be between 0 and 100, got {VALUE}'],
required: true,
}
const VariantSchema = new Schema(
{
name: {
type: String,
minLength: MIN_VARIANT_NAME_LENGTH,
maxLength: MAX_VARIANT_NAME_LENGTH,
required: true,
validate: {
validator: function (input) {
return input !== null && input !== 'default' && NAME_REGEX.test(input)
},
message: `invalid, cannot be 'default' and must match: ${NAME_REGEX}, got {VALUE}`,
},
},
active: {
type: Boolean,
default: true,
required: true,
},
rolloutPercent: RolloutPercentType,
rolloutStripes: [
{
start: RolloutPercentType,
end: RolloutPercentType,
},
],
},
{ _id: false }
)
const VersionSchema = new Schema(
{
versionNumber: {
type: Number,
default: 1,
min: [1, 'must be 1 or higher, got {VALUE}'],
required: true,
},
phase: {
type: String,
default: 'alpha',
enum: ['alpha', 'beta', 'release'],
required: true,
},
active: {
type: Boolean,
default: true,
required: true,
},
variants: [VariantSchema],
},
{ _id: false }
)
const SplitTestSchema = new Schema({
name: {
type: String,
minLength: MIN_NAME_LENGTH,
maxlength: MAX_NAME_LENGTH,
required: true,
unique: true,
validate: {
validator: function (input) {
return input !== null && NAME_REGEX.test(input)
},
message: `invalid, must match: ${NAME_REGEX}`,
},
},
versions: [VersionSchema],
forbidReleasePhase: {
type: Boolean,
required: false,
},
})
SplitTestSchema.methods.getCurrentVersion = function () {
if (this.versions && this.versions.length > 0) {
return _.maxBy(this.versions, 'versionNumber')
} else {
return undefined
}
}
SplitTestSchema.methods.getVersion = function (versionNumber) {
return _.find(this.versions || [], {
versionNumber,
})
}
module.exports = {
SplitTest: mongoose.model('SplitTest', SplitTestSchema),
SplitTestSchema,
}

View file

@ -14856,6 +14856,74 @@
} }
} }
}, },
"cache-flow": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/cache-flow/-/cache-flow-1.7.4.tgz",
"integrity": "sha512-EbU+Gcqddasv1hkUtqMTcEjP5HF+Gu7o0qDikaahsYwnu8ysusJfGa1GtUM/rfimFY7QrOO3p5sIFALrztyt0w==",
"requires": {
"cluster": "^0.7.7",
"date-fns": "^2.16.1",
"ioredis": "^4.19.2",
"lru-cache-for-clusters-as-promised": "^1.5.24",
"object-hash": "^2.0.3",
"typescript": "^4.0.5"
},
"dependencies": {
"debug": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
"integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
"requires": {
"ms": "2.1.2"
}
},
"ioredis": {
"version": "4.27.6",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.27.6.tgz",
"integrity": "sha512-6W3ZHMbpCa8ByMyC1LJGOi7P2WiOKP9B3resoZOVLDhi+6dDBOW+KNsRq3yI36Hmnb2sifCxHX+YSarTeXh48A==",
"requires": {
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.1",
"denque": "^1.1.0",
"lodash.defaults": "^4.2.0",
"lodash.flatten": "^4.4.0",
"p-map": "^2.1.0",
"redis-commands": "1.7.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
}
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"p-map": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
"integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="
},
"redis-commands": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz",
"integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ=="
},
"redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=",
"requires": {
"redis-errors": "^1.0.0"
}
},
"standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="
}
}
},
"cacheable-request": { "cacheable-request": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz",
@ -15049,6 +15117,15 @@
"check-error": "^1.0.2" "check-error": "^1.0.2"
} }
}, },
"chai-exclude": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/chai-exclude/-/chai-exclude-2.0.3.tgz",
"integrity": "sha512-6VuTQX25rsh4hKPdLzsOtL20k9+tszksLQrLtsu6szTmSVJP9+gUkqYUsyM+xqCeGZKeRJCsamCMRUQJhWsQ+g==",
"dev": true,
"requires": {
"fclone": "^1.0.11"
}
},
"chaid": { "chaid": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/chaid/-/chaid-1.0.2.tgz", "resolved": "https://registry.npmjs.org/chaid/-/chaid-1.0.2.tgz",
@ -15467,6 +15544,15 @@
} }
} }
}, },
"cluster": {
"version": "0.7.7",
"resolved": "https://registry.npmjs.org/cluster/-/cluster-0.7.7.tgz",
"integrity": "sha1-5JfiZ8yVa9CwUTrbSqOTNX0Ahe8=",
"requires": {
"log": ">= 1.2.0",
"mkdirp": ">= 0.0.1"
}
},
"cluster-key-slot": { "cluster-key-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
@ -16405,6 +16491,14 @@
} }
} }
}, },
"cron": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/cron/-/cron-1.8.2.tgz",
"integrity": "sha512-Gk2c4y6xKEO8FSAUTklqtfSr7oTq0CiPQeLBG5Fl0qoXpZyMcj1SG59YL+hqq04bu6/IuEA7lMkYDAplQNKkyg==",
"requires": {
"moment-timezone": "^0.5.x"
}
},
"cron-parser": { "cron-parser": {
"version": "2.16.3", "version": "2.16.3",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-2.16.3.tgz", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-2.16.3.tgz",
@ -16878,6 +16972,15 @@
"integrity": "sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A==", "integrity": "sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A==",
"dev": true "dev": true
}, },
"d": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz",
"integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==",
"requires": {
"es5-ext": "^0.10.50",
"type": "^1.0.1"
}
},
"d3": { "d3": {
"version": "3.5.16", "version": "3.5.16",
"resolved": "https://registry.npmjs.org/d3/-/d3-3.5.16.tgz", "resolved": "https://registry.npmjs.org/d3/-/d3-3.5.16.tgz",
@ -16886,7 +16989,7 @@
"d64": { "d64": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/d64/-/d64-1.0.0.tgz", "resolved": "https://registry.npmjs.org/d64/-/d64-1.0.0.tgz",
"integrity": "sha512-5eNy3WZziVYnrogqgXhcdEmqcDB2IHurTqLcrgssJsfkMVCUoUaZpK6cJjxxvLV2dUm5SuJMNcYfVGoin9UIRw==" "integrity": "sha1-QAKofoUMv8n52XBrYPymE6MzbpA="
}, },
"damerau-levenshtein": { "damerau-levenshtein": {
"version": "1.0.6", "version": "1.0.6",
@ -16923,6 +17026,11 @@
"resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-0.14.2.tgz", "resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-0.14.2.tgz",
"integrity": "sha512-EFTCh9zRSEpGPmJaexg7HTuzZHh6cnJj1ui7IGCFNXzd2QdpsNh05Db5TF3xzJm30YN+A8/6xHSuRcQqoc3kFA==" "integrity": "sha512-EFTCh9zRSEpGPmJaexg7HTuzZHh6cnJj1ui7IGCFNXzd2QdpsNh05Db5TF3xzJm30YN+A8/6xHSuRcQqoc3kFA=="
}, },
"date-fns": {
"version": "2.22.1",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.22.1.tgz",
"integrity": "sha512-yUFPQjrxEmIsMqlHhAhmxkuH769baF21Kk+nZwZGyrMoyLA+LugaQtC0+Tqf9CBUUULWwUJt6Q5ySI3LJDDCGg=="
},
"date-format": { "date-format": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/date-format/-/date-format-2.1.0.tgz", "resolved": "https://registry.npmjs.org/date-format/-/date-format-2.1.0.tgz",
@ -17693,6 +17801,15 @@
} }
} }
}, },
"duration": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/duration/-/duration-0.2.2.tgz",
"integrity": "sha512-06kgtea+bGreF5eKYgI/36A6pLXggY7oR4p1pq4SmdFBn1ReOL5D8RhG64VrqfTTKNucqqtBAwEj8aB88mcqrg==",
"requires": {
"d": "1",
"es5-ext": "~0.10.46"
}
},
"east": { "east": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/east/-/east-2.0.2.tgz", "resolved": "https://registry.npmjs.org/east/-/east-2.0.2.tgz",
@ -18160,12 +18277,32 @@
"is-symbol": "^1.0.1" "is-symbol": "^1.0.1"
} }
}, },
"es5-ext": {
"version": "0.10.53",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz",
"integrity": "sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==",
"requires": {
"es6-iterator": "~2.0.3",
"es6-symbol": "~3.1.3",
"next-tick": "~1.0.0"
}
},
"es5-shim": { "es5-shim": {
"version": "4.5.15", "version": "4.5.15",
"resolved": "https://registry.npmjs.org/es5-shim/-/es5-shim-4.5.15.tgz", "resolved": "https://registry.npmjs.org/es5-shim/-/es5-shim-4.5.15.tgz",
"integrity": "sha512-FYpuxEjMeDvU4rulKqFdukQyZSTpzhg4ScQHrAosrlVpR6GFyaw14f74yn2+4BugniIS0Frpg7TvwZocU4ZMTw==", "integrity": "sha512-FYpuxEjMeDvU4rulKqFdukQyZSTpzhg4ScQHrAosrlVpR6GFyaw14f74yn2+4BugniIS0Frpg7TvwZocU4ZMTw==",
"dev": true "dev": true
}, },
"es6-iterator": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
"integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=",
"requires": {
"d": "1",
"es5-ext": "^0.10.35",
"es6-symbol": "^3.1.1"
}
},
"es6-promise": { "es6-promise": {
"version": "4.2.8", "version": "4.2.8",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
@ -18177,6 +18314,15 @@
"integrity": "sha512-EmTr31wppcaIAgblChZiuN/l9Y7DPyw8Xtbg7fIVngn6zMW+IEBJDJngeKC3x6wr0V/vcA2wqeFnaw1bFJbDdA==", "integrity": "sha512-EmTr31wppcaIAgblChZiuN/l9Y7DPyw8Xtbg7fIVngn6zMW+IEBJDJngeKC3x6wr0V/vcA2wqeFnaw1bFJbDdA==",
"dev": true "dev": true
}, },
"es6-symbol": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz",
"integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==",
"requires": {
"d": "^1.0.1",
"ext": "^1.1.2"
}
},
"escalade": { "escalade": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@ -19206,6 +19352,15 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="
}, },
"event-emitter": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
"integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=",
"requires": {
"d": "1",
"es5-ext": "~0.10.14"
}
},
"event-target-shim": { "event-target-shim": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@ -19545,6 +19700,21 @@
"resolved": "https://registry.npmjs.org/expressionify/-/expressionify-0.9.3.tgz", "resolved": "https://registry.npmjs.org/expressionify/-/expressionify-0.9.3.tgz",
"integrity": "sha1-/iJnx+hpRXfxP02oML/DyNgXf5I=" "integrity": "sha1-/iJnx+hpRXfxP02oML/DyNgXf5I="
}, },
"ext": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz",
"integrity": "sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==",
"requires": {
"type": "^2.0.0"
},
"dependencies": {
"type": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/type/-/type-2.5.0.tgz",
"integrity": "sha512-180WMDQaIMm3+7hGXWf12GtdniDEy7nYcyFMKJn/eZz/6tSLXrUN9V0wKSbMjej0I1WHWbpREDEKHtqPQa9NNw=="
}
}
},
"extend": { "extend": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@ -19791,6 +19961,12 @@
"bser": "2.1.1" "bser": "2.1.1"
} }
}, },
"fclone": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/fclone/-/fclone-1.0.11.tgz",
"integrity": "sha1-EOhdo4v+p/xZk0HClu4ddyZu5kA=",
"dev": true
},
"fd-slicer": { "fd-slicer": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
@ -24967,7 +25143,7 @@
"lodash.camelcase": { "lodash.camelcase": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY="
}, },
"lodash.debounce": { "lodash.debounce": {
"version": "4.0.8", "version": "4.0.8",
@ -25073,6 +25249,19 @@
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
"dev": true "dev": true
}, },
"log": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/log/-/log-6.0.0.tgz",
"integrity": "sha512-sxChESNYJ/EcQv8C7xpmxhtTOngoXuMEqGDAkhXBEmt3MAzM3SM/TmIBOqnMEVdrOv1+VgZoYbo6U2GemQiU4g==",
"requires": {
"d": "^1.0.0",
"duration": "^0.2.2",
"es5-ext": "^0.10.49",
"event-emitter": "^0.3.5",
"sprintf-kit": "^2.0.0",
"type": "^1.0.1"
}
},
"log-driver": { "log-driver": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.7.tgz", "resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.7.tgz",
@ -25208,6 +25397,37 @@
"yallist": "^4.0.0" "yallist": "^4.0.0"
} }
}, },
"lru-cache-for-clusters-as-promised": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/lru-cache-for-clusters-as-promised/-/lru-cache-for-clusters-as-promised-1.7.1.tgz",
"integrity": "sha512-BSfUYlSHpCrPSc20F0mhk+EDSYhTuM5AK0gvj63mtADIdZVcq3KVR1pxdmgeAEXvsV/o0qyHMKbuhVg0OHWWFg==",
"requires": {
"cron": "1.8.2",
"debug": "4.3.1",
"lru-cache": "6.0.0",
"uuid": "8.3.2"
},
"dependencies": {
"debug": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
"integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
"requires": {
"ms": "2.1.2"
}
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
}
}
},
"lz-string": { "lz-string": {
"version": "1.4.4", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
@ -26637,6 +26857,11 @@
"integrity": "sha512-AO81vsIO1k1sM4Zrd6Hu7regmJN1NSiAja10gc4bX3F0wd+9rQmcuHQaHVQCYIEC8iFXnE+mavh23GOt7wBgug==", "integrity": "sha512-AO81vsIO1k1sM4Zrd6Hu7regmJN1NSiAja10gc4bX3F0wd+9rQmcuHQaHVQCYIEC8iFXnE+mavh23GOt7wBgug==",
"dev": true "dev": true
}, },
"next-tick": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
"integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw="
},
"ngcomponent": { "ngcomponent": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/ngcomponent/-/ngcomponent-4.1.0.tgz", "resolved": "https://registry.npmjs.org/ngcomponent/-/ngcomponent-4.1.0.tgz",
@ -33791,6 +34016,14 @@
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true "dev": true
}, },
"sprintf-kit": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/sprintf-kit/-/sprintf-kit-2.0.1.tgz",
"integrity": "sha512-2PNlcs3j5JflQKcg4wpdqpZ+AjhQJ2OZEo34NXDtlB0tIPG84xaaXhpA8XFacFiwjKA4m49UOYG83y3hbMn/gQ==",
"requires": {
"es5-ext": "^0.10.53"
}
},
"srcset": { "srcset": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/srcset/-/srcset-2.0.1.tgz", "resolved": "https://registry.npmjs.org/srcset/-/srcset-2.0.1.tgz",
@ -35805,7 +36038,7 @@
"timed-out": { "timed-out": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz", "resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz",
"integrity": "sha512-G7r3AhovYtr5YKOWQkta8RKAPb+J9IsO4uVmzjl8AZwfhs8UcUwTiD6gcJYSgOtzyjvQKrKYn41syHbUWMkafA==" "integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8="
}, },
"timekeeper": { "timekeeper": {
"version": "2.2.0", "version": "2.2.0",
@ -36108,6 +36341,11 @@
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
}, },
"type": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz",
"integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg=="
},
"type-check": { "type-check": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
@ -36166,6 +36404,11 @@
"is-typedarray": "^1.0.0" "is-typedarray": "^1.0.0"
} }
}, },
"typescript": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz",
"integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA=="
},
"ua-parser-js": { "ua-parser-js": {
"version": "0.7.21", "version": "0.7.21",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.21.tgz", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.21.tgz",

View file

@ -77,6 +77,7 @@
"bufferedstream": "1.6.0", "bufferedstream": "1.6.0",
"bull": "^3.18.0", "bull": "^3.18.0",
"bunyan": "^1.8.15", "bunyan": "^1.8.15",
"cache-flow": "^1.7.4",
"celebrate": "^10.0.1", "celebrate": "^10.0.1",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"codemirror": "^5.33.0", "codemirror": "^5.33.0",
@ -194,6 +195,7 @@
"c8": "^7.2.0", "c8": "^7.2.0",
"chai": "^4.2.0", "chai": "^4.2.0",
"chai-as-promised": "^7.1.1", "chai-as-promised": "^7.1.1",
"chai-exclude": "^2.0.3",
"chaid": "^1.0.2", "chaid": "^1.0.2",
"cheerio": "^1.0.0-rc.3", "cheerio": "^1.0.0-rc.3",
"copy-webpack-plugin": "^5.1.1", "copy-webpack-plugin": "^5.1.1",