mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #4346 from overleaf/ab-configurable-split-test-2
Configurable Split Tests GitOrigin-RevId: e648a77848ddb8b8b55a95887f87cf7cdd300ee9
This commit is contained in:
parent
9468e5cb4f
commit
51546b29c4
7 changed files with 797 additions and 3 deletions
25
services/web/app/src/Features/SplitTests/SplitTestCache.js
Normal file
25
services/web/app/src/Features/SplitTests/SplitTestCache.js
Normal 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()
|
|
@ -114,8 +114,14 @@ function _getPercentile(userId, splitTestId) {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* @deprecated: use SplitTestV2Handler.getAssignment instead
|
||||
*/
|
||||
getTestSegmentation: callbackify(getTestSegmentation),
|
||||
promises: {
|
||||
/**
|
||||
* @deprecated: use SplitTestV2Handler.promises.getAssignment instead
|
||||
*/
|
||||
getTestSegmentation,
|
||||
},
|
||||
}
|
||||
|
|
230
services/web/app/src/Features/SplitTests/SplitTestManager.js
Normal file
230
services/web/app/src/Features/SplitTests/SplitTestManager.js
Normal 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,
|
||||
}
|
177
services/web/app/src/Features/SplitTests/SplitTestV2Handler.js
Normal file
177
services/web/app/src/Features/SplitTests/SplitTestV2Handler.js
Normal 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,
|
||||
},
|
||||
}
|
111
services/web/app/src/models/SplitTest.js
Normal file
111
services/web/app/src/models/SplitTest.js
Normal 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,
|
||||
}
|
249
services/web/package-lock.json
generated
249
services/web/package-lock.json
generated
|
@ -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": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz",
|
||||
|
@ -15049,6 +15117,15 @@
|
|||
"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": {
|
||||
"version": "1.0.2",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"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": {
|
||||
"version": "2.16.3",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-2.16.3.tgz",
|
||||
|
@ -16878,6 +16972,15 @@
|
|||
"integrity": "sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A==",
|
||||
"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": {
|
||||
"version": "3.5.16",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-3.5.16.tgz",
|
||||
|
@ -16886,7 +16989,7 @@
|
|||
"d64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d64/-/d64-1.0.0.tgz",
|
||||
"integrity": "sha512-5eNy3WZziVYnrogqgXhcdEmqcDB2IHurTqLcrgssJsfkMVCUoUaZpK6cJjxxvLV2dUm5SuJMNcYfVGoin9UIRw=="
|
||||
"integrity": "sha1-QAKofoUMv8n52XBrYPymE6MzbpA="
|
||||
},
|
||||
"damerau-levenshtein": {
|
||||
"version": "1.0.6",
|
||||
|
@ -16923,6 +17026,11 @@
|
|||
"resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-0.14.2.tgz",
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/east/-/east-2.0.2.tgz",
|
||||
|
@ -18160,12 +18277,32 @@
|
|||
"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": {
|
||||
"version": "4.5.15",
|
||||
"resolved": "https://registry.npmjs.org/es5-shim/-/es5-shim-4.5.15.tgz",
|
||||
"integrity": "sha512-FYpuxEjMeDvU4rulKqFdukQyZSTpzhg4ScQHrAosrlVpR6GFyaw14f74yn2+4BugniIS0Frpg7TvwZocU4ZMTw==",
|
||||
"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": {
|
||||
"version": "4.2.8",
|
||||
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
|
||||
|
@ -18177,6 +18314,15 @@
|
|||
"integrity": "sha512-EmTr31wppcaIAgblChZiuN/l9Y7DPyw8Xtbg7fIVngn6zMW+IEBJDJngeKC3x6wr0V/vcA2wqeFnaw1bFJbDdA==",
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "5.0.1",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
|
@ -19791,6 +19961,12 @@
|
|||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
|
@ -24967,7 +25143,7 @@
|
|||
"lodash.camelcase": {
|
||||
"version": "4.3.0",
|
||||
"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": {
|
||||
"version": "4.0.8",
|
||||
|
@ -25073,6 +25249,19 @@
|
|||
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
|
||||
"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": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.7.tgz",
|
||||
|
@ -25208,6 +25397,37 @@
|
|||
"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": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
|
||||
|
@ -26637,6 +26857,11 @@
|
|||
"integrity": "sha512-AO81vsIO1k1sM4Zrd6Hu7regmJN1NSiAja10gc4bX3F0wd+9rQmcuHQaHVQCYIEC8iFXnE+mavh23GOt7wBgug==",
|
||||
"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": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ngcomponent/-/ngcomponent-4.1.0.tgz",
|
||||
|
@ -33791,6 +34016,14 @@
|
|||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/srcset/-/srcset-2.0.1.tgz",
|
||||
|
@ -35805,7 +36038,7 @@
|
|||
"timed-out": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz",
|
||||
"integrity": "sha512-G7r3AhovYtr5YKOWQkta8RKAPb+J9IsO4uVmzjl8AZwfhs8UcUwTiD6gcJYSgOtzyjvQKrKYn41syHbUWMkafA=="
|
||||
"integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8="
|
||||
},
|
||||
"timekeeper": {
|
||||
"version": "2.2.0",
|
||||
|
@ -36108,6 +36341,11 @@
|
|||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
"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": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
|
||||
|
@ -36166,6 +36404,11 @@
|
|||
"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": {
|
||||
"version": "0.7.21",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.21.tgz",
|
||||
|
|
|
@ -77,6 +77,7 @@
|
|||
"bufferedstream": "1.6.0",
|
||||
"bull": "^3.18.0",
|
||||
"bunyan": "^1.8.15",
|
||||
"cache-flow": "^1.7.4",
|
||||
"celebrate": "^10.0.1",
|
||||
"classnames": "^2.2.6",
|
||||
"codemirror": "^5.33.0",
|
||||
|
@ -194,6 +195,7 @@
|
|||
"c8": "^7.2.0",
|
||||
"chai": "^4.2.0",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"chai-exclude": "^2.0.3",
|
||||
"chaid": "^1.0.2",
|
||||
"cheerio": "^1.0.0-rc.3",
|
||||
"copy-webpack-plugin": "^5.1.1",
|
||||
|
|
Loading…
Reference in a new issue