diff --git a/NEWS.md b/NEWS.md index 24a86655..230774ac 100644 --- a/NEWS.md +++ b/NEWS.md @@ -7,6 +7,7 @@ Announcements: - Upgrades windshaft to [4.1.1](https://github.com/CartoDB/windshaft/releases/tag/4.1.1). - Validate aggregation input params - Fix column names collisions in histograms [#828](https://github.com/CartoDB/Windshaft-cartodb/pull/828) + - Add full-sample aggregation support for vector map-config. ## 4.5.0 diff --git a/lib/cartodb/models/aggregation/aggregation-mapconfig.js b/lib/cartodb/models/aggregation/aggregation-mapconfig.js index bf334338..99ed32c1 100644 --- a/lib/cartodb/models/aggregation/aggregation-mapconfig.js +++ b/lib/cartodb/models/aggregation/aggregation-mapconfig.js @@ -7,6 +7,19 @@ const { createAggregationColumnsValidator } = aggregationValidator; +const SubstitutionTokens = require('../../utils/substitution-tokens'); + +const removeDuplicates = arr => [...new Set(arr)]; + +function prepareSql(sql) { + return sql && SubstitutionTokens.replace(sql, { + bbox: 'ST_MakeEnvelope(0,0,0,0)', + scale_denominator: '0', + pixel_width: '1', + pixel_height: '1' + }); +} + module.exports = class AggregationMapConfig extends MapConfig { static get AGGREGATIONS () { return aggregationQuery.SUPPORTED_AGGREGATE_FUNCTIONS; @@ -16,10 +29,6 @@ module.exports = class AggregationMapConfig extends MapConfig { return aggregationQuery.SUPPORTED_PLACEMENTS; } - static get PLACEMENT () { - return AggregationMapConfig.PLACEMENTS.find(placement => placement === 'centroid'); - } - static get THRESHOLD () { return 1e5; // 100K } @@ -38,7 +47,7 @@ module.exports = class AggregationMapConfig extends MapConfig { return AggregationMapConfig.SUPPORTED_GEOMETRY_TYPES.includes(geometryType); } - constructor (config, datasource) { + constructor (user, config, connection, datasource) { super(config, datasource); const validate = aggregationValidator(this); @@ -50,14 +59,19 @@ module.exports = class AggregationMapConfig extends MapConfig { validate('placement', includesValidPlacementsValidator); validate('threshold', positiveNumberValidator); validate('columns', aggregationColumnsValidator); + + this.user = user; + this.pgConnection = connection; } getAggregatedQuery (index) { const { sql_raw, sql } = this.getLayer(index).options; const { + // The default aggregation has no placement, columns or dimensions; + // this enables the special "full-sample" aggregation. resolution = AggregationMapConfig.RESOLUTION, threshold = AggregationMapConfig.THRESHOLD, - placement = AggregationMapConfig.PLACEMENT, + placement, columns = {}, dimensions = {} } = this.getAggregation(index); @@ -68,7 +82,8 @@ module.exports = class AggregationMapConfig extends MapConfig { threshold, placement, columns, - dimensions + dimensions, + isDefaultAggregation: this._isDefaultLayerAggregation(index) }); } @@ -100,8 +115,8 @@ module.exports = class AggregationMapConfig extends MapConfig { } getAggregation (index) { - if (!this.hasLayerAggregation(index)) { - return; + if (this.isVectorOnlyMapConfig() && !this.hasLayerAggregation(index)) { + return {}; } const { aggregation } = this.getLayer(index).options; @@ -113,6 +128,43 @@ module.exports = class AggregationMapConfig extends MapConfig { return aggregation; } + getLayerAggregationColumns (index, callback) { + if (this._isDefaultLayerAggregation(index)) { + const skipGeoms = true; + return this.getLayerColumns(index, skipGeoms, (err, columns) => { + if (err) { + return callback(err); + } + + return callback(null, columns); + }); + } + + const columns = this._getLayerAggregationRequiredColumns(index); + + return callback(null, columns); + } + + _getLayerAggregationRequiredColumns (index) { + const { columns, dimensions } = this.getAggregation(index); + + let aggregatedColumns = []; + if (columns) { + aggregatedColumns = Object.keys(columns) + .map(key => columns[key].aggregated_column) + .filter(aggregatedColumn => typeof aggregatedColumn === 'string'); + } + + let dimensionsColumns = []; + if (dimensions) { + dimensionsColumns = Object.keys(dimensions) + .map(key => dimensions[key]) + .filter(dimension => typeof dimension === 'string'); + } + + return removeDuplicates(aggregatedColumns.concat(dimensionsColumns)); + } + doesLayerReachThreshold(index, featureCount) { const threshold = this.getAggregation(index) && this.getAggregation(index).threshold ? this.getAggregation(index).threshold : @@ -120,4 +172,58 @@ module.exports = class AggregationMapConfig extends MapConfig { return featureCount >= threshold; } + + getLayerColumns (index, skipGeoms, callback) { + const geomColumns = ['the_geom', 'the_geom_webmercator']; + const limitedQuery = ctx => `SELECT * FROM (${ctx.query}) __cdb_schema LIMIT 0`; + const layer = this.getLayer(index); + + this.pgConnection.getConnection(this.user, (err, connection) => { + if (err) { + return callback(err); + } + + const sql = limitedQuery({ + query: prepareSql(layer.options.sql) + }); + + connection.query(sql, (err, result) => { + if (err) { + return callback(err); + } + + let columns = result.fields || []; + + columns = columns.map(({ name }) => name); + + if (skipGeoms) { + columns = columns.filter((column) => !geomColumns.includes(column)); + } + + return callback(err, columns); + }); + }); + } + + _isDefaultLayerAggregation (index) { + const aggregation = this.getAggregation(index); + + return (this.isVectorOnlyMapConfig() && !this.hasLayerAggregation(index)) || + aggregation === true || + this._isDefaultAggregation(aggregation); + } + + _isDefaultAggregation (aggregation) { + return aggregation.placement === undefined && + aggregation.columns === undefined && + this._isEmptyParameter(aggregation.dimensions); + } + + _isEmptyParameter(parameter) { + return parameter === undefined || parameter === null || this._isEmptyObject(parameter); + } + + _isEmptyObject (parameter) { + return typeof parameter === 'object' && Object.keys(parameter).length === 0; + } }; diff --git a/lib/cartodb/models/aggregation/aggregation-query.js b/lib/cartodb/models/aggregation/aggregation-query.js index d09a9665..2a09efde 100644 --- a/lib/cartodb/models/aggregation/aggregation-query.js +++ b/lib/cartodb/models/aggregation/aggregation-query.js @@ -1,8 +1,12 @@ +const DEFAULT_PLACEMENT = 'point-sample'; + /** * Returns a template function (function that accepts template parameters and returns a string) * to generate an aggregation query. * Valid options to define the query template are: * - placement + * - columns + * - dimensions* * The query template parameters taken by the result template function are: * - sourceQuery * - res @@ -10,9 +14,12 @@ * - dimensions */ const templateForOptions = (options) => { - let templateFn = aggregationQueryTemplates[options.placement]; - if (!templateFn) { - throw new Error("Invalid Aggregation placement: '" + options.placement + "'"); + let templateFn = defaultAggregationQueryTemplate; + if (!options.isDefaultAggregation) { + templateFn = aggregationQueryTemplates[options.placement || DEFAULT_PLACEMENT]; + if (!templateFn) { + throw new Error("Invalid Aggregation placement: '" + options.placement + "'"); + } } return templateFn; }; @@ -25,6 +32,11 @@ const templateForOptions = (options) => { * - columns * - placement * - dimensions + * + * The default aggregation (when no explicit placement, columns or dimensions are present) returns + * a sample record (with all the original columns and _cdb_feature_count) for each aggregation group. + * When placement, columns or dimensions are specified, columns are aggregated as requested + * (by default only _cdb_feature_count) and with the_geom_webmercator as defined by placement. */ const queryForOptions = (options) => templateForOptions(options)({ sourceQuery: options.query, @@ -71,8 +83,13 @@ const aggregateColumns = ctx => { }, ctx.columns || {}); }; -const aggregateColumnNames = ctx => { +const aggregateColumnNames = (ctx, table) => { let columns = aggregateColumns(ctx); + if (table) { + return sep(Object.keys(columns).map( + column_name => `${table}.${column_name}` + )); + } return sep(Object.keys(columns)); }; @@ -92,8 +109,14 @@ const aggregateColumnDefs = ctx => { const aggregateDimensions = ctx => ctx.dimensions || {}; -const dimensionNames = ctx => { - return sep(Object.keys(aggregateDimensions(ctx))); +const dimensionNames = (ctx, table) => { + let dimensions = aggregateDimensions(ctx); + if (table) { + return sep(Object.keys(dimensions).map( + dimension_name => `${table}.${dimension_name}` + )); + } + return sep(Object.keys(dimensions)); }; const dimensionDefs = ctx => { @@ -116,9 +139,38 @@ const gridResolution = ctx => `(${256*0.00028/ctx.res}*!scale_denominator!)::dou // is only applied after the aggregation. // * This queries are used for rendering and the_geom is omitted in the results for better performance +// The special default aggregation includes all the columns of a sample row per grid cell and +// the count (_cdb_feature_count) of the aggregated rows. +const defaultAggregationQueryTemplate = ctx => ` + WITH + _cdb_params AS ( + SELECT + ${gridResolution(ctx)} AS res, + !bbox! AS bbox + ), + _cdb_clusters AS ( + SELECT + MIN(cartodb_id) AS cartodb_id + ${dimensionDefs(ctx)} + ${aggregateColumnDefs(ctx)} + FROM (${ctx.sourceQuery}) _cdb_query, _cdb_params + WHERE _cdb_query.the_geom_webmercator && _cdb_params.bbox + GROUP BY + Floor(ST_X(_cdb_query.the_geom_webmercator)/_cdb_params.res), + Floor(ST_Y(_cdb_query.the_geom_webmercator)/_cdb_params.res) + ${dimensionNames(ctx)} + ) SELECT + _cdb_query.* + ${aggregateColumnNames(ctx)} + FROM + _cdb_clusters INNER JOIN (${ctx.sourceQuery}) _cdb_query + ON (_cdb_clusters.cartodb_id = _cdb_query.cartodb_id) +`; + const aggregationQueryTemplates = { 'centroid': ctx => ` - WITH _cdb_params AS ( + WITH + _cdb_params AS ( SELECT ${gridResolution(ctx)} AS res, !bbox! AS bbox @@ -142,34 +194,37 @@ const aggregationQueryTemplates = { `, 'point-grid': ctx => ` - WITH _cdb_params AS ( - SELECT - ${gridResolution(ctx)} AS res, - !bbox! AS bbox - ), - _cdb_clusters AS ( - SELECT - Floor(ST_X(_cdb_query.the_geom_webmercator)/_cdb_params.res)::int AS _cdb_gx, - Floor(ST_Y(_cdb_query.the_geom_webmercator)/_cdb_params.res)::int AS _cdb_gy - ${dimensionDefs(ctx)} - ${aggregateColumnDefs(ctx)} - FROM (${ctx.sourceQuery}) _cdb_query, _cdb_params - WHERE the_geom_webmercator && _cdb_params.bbox - GROUP BY _cdb_gx, _cdb_gy ${dimensionNames(ctx)} - ) - SELECT - ST_SetSRID(ST_MakePoint((_cdb_gx+0.5)*res, (_cdb_gy+0.5)*res), 3857) AS the_geom_webmercator - ${dimensionNames(ctx)} - ${aggregateColumnNames(ctx)} - FROM _cdb_clusters, _cdb_params - `, - - 'point-sample': ctx => ` - WITH _cdb_params AS ( + WITH + _cdb_params AS ( SELECT ${gridResolution(ctx)} AS res, !bbox! AS bbox - ), _cdb_clusters AS ( + ), + _cdb_clusters AS ( + SELECT + Floor(ST_X(_cdb_query.the_geom_webmercator)/_cdb_params.res)::int AS _cdb_gx, + Floor(ST_Y(_cdb_query.the_geom_webmercator)/_cdb_params.res)::int AS _cdb_gy + ${dimensionDefs(ctx)} + ${aggregateColumnDefs(ctx)} + FROM (${ctx.sourceQuery}) _cdb_query, _cdb_params + WHERE the_geom_webmercator && _cdb_params.bbox + GROUP BY _cdb_gx, _cdb_gy ${dimensionNames(ctx)} + ) + SELECT + ST_SetSRID(ST_MakePoint((_cdb_gx+0.5)*res, (_cdb_gy+0.5)*res), 3857) AS the_geom_webmercator + ${dimensionNames(ctx)} + ${aggregateColumnNames(ctx)} + FROM _cdb_clusters, _cdb_params + `, + + 'point-sample': ctx => ` + WITH + _cdb_params AS ( + SELECT + ${gridResolution(ctx)} AS res, + !bbox! AS bbox + ), + _cdb_clusters AS ( SELECT MIN(cartodb_id) AS cartodb_id ${dimensionDefs(ctx)} @@ -180,15 +235,17 @@ const aggregationQueryTemplates = { Floor(ST_X(_cdb_query.the_geom_webmercator)/_cdb_params.res), Floor(ST_Y(_cdb_query.the_geom_webmercator)/_cdb_params.res) ${dimensionNames(ctx)} - ) SELECT + ) + SELECT _cdb_clusters.cartodb_id, the_geom, the_geom_webmercator - ${dimensionNames(ctx)} - ${aggregateColumnNames(ctx)} + ${dimensionNames(ctx, '_cdb_query')} + ${aggregateColumnNames(ctx, '_cdb_clusters')} FROM _cdb_clusters INNER JOIN (${ctx.sourceQuery}) _cdb_query ON (_cdb_clusters.cartodb_id = _cdb_query.cartodb_id) ` + }; module.exports.SUPPORTED_PLACEMENTS = Object.keys(aggregationQueryTemplates); diff --git a/lib/cartodb/models/mapconfig/adapter/aggregation-mapconfig-adapter.js b/lib/cartodb/models/mapconfig/adapter/aggregation-mapconfig-adapter.js index 425aade7..28cbffc9 100644 --- a/lib/cartodb/models/mapconfig/adapter/aggregation-mapconfig-adapter.js +++ b/lib/cartodb/models/mapconfig/adapter/aggregation-mapconfig-adapter.js @@ -20,7 +20,7 @@ module.exports = class AggregationMapConfigAdapter { let mapConfig; try { - mapConfig = new AggregationMapConfig(requestMapConfig); + mapConfig = new AggregationMapConfig(user, requestMapConfig, this.pgConnection); } catch (err) { return callback(err); } @@ -89,19 +89,29 @@ module.exports = class AggregationMapConfigAdapter { return reject(err); } - if (shouldAdapt) { - const sqlQueryWrap = layer.options.sql_wrap; - - let aggregationSql = mapConfig.getAggregatedQuery(index); - - if (sqlQueryWrap) { - aggregationSql = sqlQueryWrap.replace(/<%=\s*sql\s*%>/g, aggregationSql); - } - - layer.options.sql = aggregationSql; + if (!shouldAdapt) { + return resolve({ layer, index, adapted: shouldAdapt }); } - return resolve({ layer, index, adapted: shouldAdapt }); + const sqlQueryWrap = layer.options.sql_wrap; + + let aggregationSql = mapConfig.getAggregatedQuery(index); + + if (sqlQueryWrap) { + aggregationSql = sqlQueryWrap.replace(/<%=\s*sql\s*%>/g, aggregationSql); + } + + layer.options.sql = aggregationSql; + + mapConfig.getLayerAggregationColumns(index, (err, columns) => { + if (err) { + return reject(err); + } + + layer.options.columns = columns; + + return resolve({ layer, index, adapted: shouldAdapt }); + }); }); }); } diff --git a/package.json b/package.json index f7386669..83e14970 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "step-profiler": "~0.3.0", "turbo-carto": "0.20.2", "underscore": "~1.6.0", - "windshaft": "4.1.1", + "windshaft": "4.2.0", "yargs": "~5.0.0" }, "devDependencies": { @@ -65,7 +65,7 @@ "update-internal-deps": "rm -rf node_modules && rm -f yarn.lock && yarn", "docker-install": "sudo apt install docker.io && sudo usermod -aG docker $(whoami)", "docker-pull": "docker pull cartoimages/windshaft-testing", - "docker-test": "docker run -v `pwd`:/srv cartoimages/windshaft-testing bash docker-test.sh", + "docker-test": "docker run -v `pwd`:/srv cartoimages/windshaft-testing bash docker-test.sh && docker ps --filter status=dead --filter status=exited -aq | xargs -r docker rm -v", "docker-bash": "docker run -it -v `pwd`:/srv cartoimages/windshaft-testing bash", "docker-publish": "docker push cartoimages/windshaft-carto-testing" }, diff --git a/test/acceptance/aggregation.js b/test/acceptance/aggregation.js index 12dffeee..30cfd9db 100644 --- a/test/acceptance/aggregation.js +++ b/test/acceptance/aggregation.js @@ -289,7 +289,8 @@ describe('aggregation', function () { options: { sql: POINTS_SQL_2, aggregation: { - threshold: 1 + threshold: 1, + placement: 'centroid' }, cartocss: '#layer { marker-width: [value]; }', cartocss_version: '2.3.0' @@ -309,6 +310,45 @@ describe('aggregation', function () { }); }); + it('should provide all columns in the default aggregation ', + function (done) { + const response = { + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + } + }; + + this.mapConfig = createVectorMapConfig([ + { + type: 'cartodb', + options: { + sql: POINTS_SQL_2, + aggregation: { + threshold: 1 + }, + cartocss: '#layer { marker-width: [value]; }', + cartocss_version: '2.3.0' + } + } + ]); + + this.testClient = new TestClient(this.mapConfig); + this.testClient.getLayergroup({ response }, (err, body) => { + if (err) { + return done(err); + } + + + assert.equal(typeof body.metadata, 'object'); + assert.ok(Array.isArray(body.metadata.layers)); + + body.metadata.layers.forEach(layer => assert.ok(layer.meta.aggregation.mvt)); + body.metadata.layers.forEach(layer => assert.ok(layer.meta.aggregation.png)); + done(); + }); + }); + it('should skip aggregation to create a layergroup with aggregation defined already', function (done) { const mapConfig = createVectorMapConfig([ { @@ -531,12 +571,6 @@ describe('aggregation', function () { it('when dimensions is provided should return a tile returning the column used as dimensions', function (done) { - - // FIXME: skip until pg-mvt renderer is able to return all columns - if (process.env.POSTGIS_VERSION === '2.4') { - return done(); - } - this.mapConfig = createVectorMapConfig([ { type: 'cartodb', @@ -569,6 +603,164 @@ describe('aggregation', function () { }); }); + ['centroid', 'point-sample', 'point-grid'].forEach(placement => { + it(`dimensions should work for ${placement} placement`, function(done) { + this.mapConfig = createVectorMapConfig([ + { + type: 'cartodb', + options: { + sql: POINTS_SQL_1, + aggregation: { + placement: placement , + threshold: 1, + dimensions: { + value: "value" + } + } + } + } + ]); + + this.testClient = new TestClient(this.mapConfig); + const options = { + format: 'mvt' + }; + this.testClient.getTile(0, 0, 0, options, (err, res, tile) => { + if (err) { + return done(err); + } + + const tileJSON = tile.toJSON(); + + tileJSON[0].features.forEach( + feature => assert.equal(typeof feature.properties.value, 'number') + ); + + done(); + }); + }); + }); + + it(`dimensions should trigger non-default aggregation`, function(done) { + this.mapConfig = createVectorMapConfig([ + { + type: 'cartodb', + options: { + sql: POINTS_SQL_2, + aggregation: { + threshold: 1, + dimensions: { + value: "value" + } + } + } + } + ]); + + this.testClient = new TestClient(this.mapConfig); + const options = { + format: 'mvt' + }; + this.testClient.getTile(0, 0, 0, options, (err, res, tile) => { + if (err) { + return done(err); + } + + const tileJSON = tile.toJSON(); + + tileJSON[0].features.forEach( + feature => assert.equal(typeof feature.properties.value, 'number') + ); + tileJSON[0].features.forEach( + feature => assert.equal(typeof feature.properties.sqrt_value, 'undefined') + ); + + done(); + }); + }); + + it(`aggregation columns should trigger non-default aggregation`, function(done) { + this.mapConfig = createVectorMapConfig([ + { + type: 'cartodb', + options: { + sql: POINTS_SQL_2, + aggregation: { + threshold: 1, + columns: { + value: { + aggregate_function: 'sum', + aggregated_column: 'value' + } + } + } + } + } + ]); + + this.testClient = new TestClient(this.mapConfig); + const options = { + format: 'mvt' + }; + this.testClient.getTile(0, 0, 0, options, (err, res, tile) => { + if (err) { + return done(err); + } + + const tileJSON = tile.toJSON(); + + tileJSON[0].features.forEach( + feature => assert.equal(typeof feature.properties.value, 'number') + ); + tileJSON[0].features.forEach( + feature => assert.equal(typeof feature.properties.sqrt_value, 'undefined') + ); + + done(); + }); + }); + + ['centroid', 'point-sample', 'point-grid'].forEach(placement => { + it(`aggregations with base column names should work for ${placement} placement`, function(done) { + this.mapConfig = createVectorMapConfig([ + { + type: 'cartodb', + options: { + sql: POINTS_SQL_1, + aggregation: { + placement: placement , + threshold: 1, + columns: { + value: { + aggregate_function: 'sum', + aggregated_column: 'value' + } + } + } + } + } + ]); + + this.testClient = new TestClient(this.mapConfig); + const options = { + format: 'mvt' + }; + this.testClient.getTile(0, 0, 0, options, (err, res, tile) => { + if (err) { + return done(err); + } + + const tileJSON = tile.toJSON(); + + tileJSON[0].features.forEach( + feature => assert.equal(typeof feature.properties.value, 'number') + ); + + done(); + }); + }); + }); + it('should work when the sql has single quotes', function (done) { this.mapConfig = createVectorMapConfig([ { @@ -576,6 +768,7 @@ describe('aggregation', function () { options: { sql: ` SELECT + cartodb_id, the_geom_webmercator, the_geom, value, @@ -732,6 +925,40 @@ describe('aggregation', function () { }); }); + it('aggregates with full-sample placement by default', function (done) { + this.mapConfig = createVectorMapConfig([ + { + type: 'cartodb', + options: { + sql: POINTS_SQL_1, + resolution: 256, + aggregation: { + threshold: 1 + } + } + } + ]); + + this.testClient = new TestClient(this.mapConfig); + + this.testClient.getTile(0, 0, 0, { format: 'mvt' }, function (err, res, mvt) { + if (err) { + return done(err); + } + + const geojsonTile = JSON.parse(mvt.toGeoJSONSync(0)); + + assert.ok(Array.isArray(geojsonTile.features)); + assert.ok(geojsonTile.features.length > 0); + + const feature = geojsonTile.features[0]; + + assert.ok(feature.properties.hasOwnProperty('value'), 'Missing value property'); + + done(); + }); + }); + it('should fail with bad resolution', function (done) { this.mapConfig = createVectorMapConfig([ { diff --git a/yarn.lock b/yarn.lock index 26e59383..6e1c0707 100644 --- a/yarn.lock +++ b/yarn.lock @@ -33,8 +33,8 @@ ajv@^4.9.1: json-stable-stringify "^1.0.1" ajv@^5.1.0: - version "5.5.1" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.5.1.tgz#b38bb8876d9e86bee994956a04e721e88b248eb2" + version "5.5.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965" dependencies: co "^4.6.0" fast-deep-equal "^1.0.0" @@ -244,9 +244,9 @@ carto@0.16.3: semver "^5.1.0" yargs "^4.2.0" -carto@CartoDB/carto#0.15.1-cdb1: +"carto@github:cartodb/carto#0.15.1-cdb1": version "0.15.1-cdb1" - resolved "https://codeload.github.com/CartoDB/carto/tar.gz/8050ec843f1f32a6469e5d1cf49602773015d398" + resolved "https://codeload.github.com/cartodb/carto/tar.gz/8050ec843f1f32a6469e5d1cf49602773015d398" dependencies: mapnik-reference "~6.0.2" optimist "~0.6.0" @@ -842,9 +842,9 @@ graceful-fs@^4.1.2: version "1.0.1" resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" -grainstore@1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/grainstore/-/grainstore-1.7.0.tgz#28d78895c82e6201f7d0ff63af1056f3c0fda0d3" +grainstore@~1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/grainstore/-/grainstore-1.8.0.tgz#9398729df88f3aecb55ffbb415d541dcca4420af" dependencies: carto "0.16.3" debug "~3.1.0" @@ -1380,8 +1380,8 @@ mocha@~3.4.1: supports-color "3.1.2" moment@^2.10.6: - version "2.19.4" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.4.tgz#17e5e2c6ead8819c8ecfad83a0acccb312e94682" + version "2.20.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.20.1.tgz#d6eb1a46cbcc14a2b2f9434112c1ff8907f313fd" moment@~2.18.1: version "2.18.1" @@ -2392,9 +2392,9 @@ window-size@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.2.0.tgz#b4315bb4214a3d7058ebeee892e13fa24d98b075" -windshaft@4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/windshaft/-/windshaft-4.1.1.tgz#cdae06202e841b8e75915de5155b8a279b4547be" +windshaft@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/windshaft/-/windshaft-4.2.0.tgz#6a0409832a0d3bccfa09a88a8ab8288686b6762d" dependencies: abaculus cartodb/abaculus#2.0.3-cdb1 canvas cartodb/node-canvas#1.6.2-cdb2 @@ -2402,7 +2402,7 @@ windshaft@4.1.1: cartodb-psql "^0.10.1" debug "^3.1.0" dot "~1.0.2" - grainstore "1.7.0" + grainstore "~1.8.0" mapnik "3.5.14" queue-async "~1.0.7" redis-mpool "0.4.1"