├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── examples ├── aws │ ├── .serverless_plugins │ │ └── serverless-plugin-tables.js │ ├── handler.js │ └── serverless.yml ├── dynamic-table-names │ ├── .serverless_plugins │ │ └── serverless-plugin-tables.js │ ├── handler.js │ └── serverless.yml └── split-resources │ ├── .serverless_plugins │ └── serverless-plugin-tables.js │ ├── handler.js │ ├── music-tables.yml │ ├── serverless.yml │ └── users-tables.yml ├── jest.base.config.js ├── jest.config.js ├── jest.integration.config.js ├── lib ├── .npmignore ├── __tests__ │ ├── __snapshots__ │ │ └── index.test.js.snap │ └── index.test.js ├── aws │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── index.test.js.snap │ │ └── index.test.js │ ├── dynamo │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ ├── addToResources.test.js.snap │ │ │ │ ├── createProperties.test.js.snap │ │ │ │ ├── handleDeploymentLimit.test.js.snap │ │ │ │ └── index.test.js.snap │ │ │ ├── addToResources.test.js │ │ │ ├── createProperties.test.js │ │ │ ├── createResources.test.js │ │ │ ├── createTemplate.test.js │ │ │ ├── handleDeploymentLimit.test.js │ │ │ ├── index.test.js │ │ │ └── mergeExistingTables.test.js │ │ ├── addToResources.js │ │ ├── constants.js │ │ ├── createProperties.js │ │ ├── createResource.js │ │ ├── createTemplate.js │ │ ├── handleDeploymentLimit.js │ │ ├── index.js │ │ ├── mergeExistingTables.js │ │ └── testUtils.js │ └── index.js ├── index.js └── utils │ ├── __tests__ │ └── index.test.js │ └── index.js ├── package.json ├── serverless-plugin-tables.code-workspace └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'airbnb-base', 3 | plugins: [ 4 | 'jest', 5 | ], 6 | env: { 7 | jest: true, 8 | }, 9 | rules: { 10 | 'arrow-parens': ['error', 'always'], 11 | 'no-continue': 'off', 12 | 13 | // Jest 14 | 'jest/no-disabled-tests': 'error', 15 | 'jest/no-focused-tests': 'error', 16 | 'jest/no-identical-title': 'warn', 17 | 'jest/prefer-to-have-length': 'warn', 18 | 'jest/valid-expect': 'warn', 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .npmrc 2 | 3 | # Created by https://www.gitignore.io/api/node,serverless,visualstudiocode 4 | # Edit at https://www.gitignore.io/?templates=node,serverless,visualstudiocode 5 | 6 | ### Node ### 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | .env.test 66 | 67 | # parcel-bundler cache (https://parceljs.org/) 68 | .cache 69 | 70 | # next.js build output 71 | .next 72 | 73 | # nuxt.js build output 74 | .nuxt 75 | 76 | # vuepress build output 77 | .vuepress/dist 78 | 79 | # Serverless directories 80 | .serverless/ 81 | 82 | # FuseBox cache 83 | .fusebox/ 84 | 85 | # DynamoDB Local files 86 | .dynamodb/ 87 | 88 | ### Serverless ### 89 | # Ignore build directory 90 | .serverless 91 | 92 | ### VisualStudioCode ### 93 | .vscode/* 94 | !.vscode/settings.json 95 | !.vscode/tasks.json 96 | !.vscode/launch.json 97 | !.vscode/extensions.json 98 | 99 | ### VisualStudioCode Patch ### 100 | # Ignore all local history of files 101 | .history 102 | 103 | # End of https://www.gitignore.io/api/node,serverless,visualstudiocode -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "8.11.3" 5 | - "node" 6 | 7 | cache: yarn 8 | 9 | install: 10 | - travis_retry yarn install 11 | - yarn global add codecov 12 | 13 | script: 14 | - yarn run lint 15 | - yarn run test 16 | - codecov 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Chris Feist 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # serverless-plugin-tables 2 | 3 | This [Serverless][link-serverless] plugin makes adding tables to your service file easy. 4 | 5 | [![Serverless][icon-serverless]][link-serverless] 6 | [![License][icon-license]][link-license] 7 | [![NPM Total Downloads][icon-npm-total-downloads]][link-npm] 8 | [![NPM Version][icon-npm-version]][link-npm] 9 | [![Build Status][icon-build-status]][link-build] 10 | [![Coverage][icon-coverage]][link-coverage] 11 | 12 | ## Benefits 13 | - Less boilerplate 14 | - Common defaults 15 | - Integrates with existing resources 16 | - Handles deployment limits 17 | - [Dynamo][link-dynamo-deployment-limit] 18 | 19 | # Contents 20 | - [Installation](#installation) 21 | - [Usage](#usage) 22 | - [Providers](#providers) 23 | - [AWS](#aws) 24 | - [DynamoDB](#dynamo-db) 25 | - [Others](#others) 26 | 27 | ## Installation 28 | 29 | Install the dependency: 30 | ```sh 31 | yarn add -D serverless-plugin-tables 32 | ``` 33 | 34 | Add `serverless-plugin-tables` to your `serverless.yml` file: 35 | 36 | ```yaml 37 | plugins: 38 | - serverless-plugin-tables 39 | ``` 40 | 41 | ## Usage 42 | 43 | Add a `tables` property the resources in your `serverless.yml` file and define tables by name according to the [provider](#providers) specs. 44 | 45 | ```yaml 46 | service: my-service 47 | 48 | plugins: 49 | - serverless-plugin-tables 50 | 51 | provider: 52 | ... 53 | 54 | resources: 55 | tables: 56 | MyTable1: 57 | ... 58 | MyTable2: 59 | ... 60 | ``` 61 | 62 | ### Plugin options 63 | 64 | The plugin can be configured by defining a custom `tables` object in your `serverless.yml` file. Database specific options should be defined as properties under their database type, like `dynamo`. See database specs for related options. Example options `serverless.yml`: 65 | 66 | ```yaml 67 | custom: 68 | tables: 69 | dynamo: 70 | deploymentBatchSize: 5 71 | ``` 72 | 73 | # Providers 74 | 75 | #### Common properties: 76 | 77 | | Property | Required | Default Value | Description | 78 | |--------------|----------|---------------|-------------| 79 | | name | *false* | The table key | The name of the table | 80 | | type | *false* | Varies by provider | The database type. Please refer to corresponding provider and database sections below. | 81 | 82 | ## AWS 83 | 84 | #### Common properties: 85 | 86 | | Property | Required | Default Value | Description | 87 | |--------------|----------|---------------|-------------| 88 | | resourceName | *false* | `'{pascalCase(tableName)}DynamoDbTable'` | The CloudFormation resource name. The default runs your table name through a pascal case transformation. | 89 | | type | *false* | `'dynamo'` | The database type. Please refer to corresponding database sections below. | 90 | | template | *false* | `null` | Custom CloudFormation template overrides. This allows you to implement features not covered, override the generated output, or do whatever other crazy stuff you have in mind 😀 | 91 | 92 | #### Example 93 | ```yaml 94 | resources: 95 | tables: 96 | # Simple DynamoDB Table 97 | Music: 98 | partitionKey: Artist 99 | sortKey: SongTitle 100 | indexes: 101 | - name: GenreByTitleIndex 102 | partitionKey: Genre 103 | sortKey: AlbumTitle 104 | 105 | # Complex DynamoDB Table 106 | People: 107 | name: ${env:PEOPLE_TABLE_NAME} 108 | resourceName: FavoritePeopleDynamoTable 109 | type: dynamo 110 | partitionKey: personID 111 | sortKey: state 112 | readUnits: 5 113 | writeUnits: 5 114 | indexes: 115 | # Global Secondary Index 116 | - name: EmailIndex 117 | partitionKey: email 118 | projection: all 119 | readUnits: 2 120 | writeUnits: 2 121 | # Local Secondary Index 122 | - name: PersonByCreatedTimeIndex 123 | sortKey: 124 | name: createdTime 125 | type: number 126 | projection: keys 127 | readUnits: 2 128 | writeUnits: 2 129 | # Local Secondary Index with projection 130 | - name: PersonByAgeIndex 131 | sortKey: 132 | name: age 133 | type: number 134 | projection: 135 | - dob 136 | - firstName 137 | - lastName 138 | readUnits: 2 139 | writeUnits: 2 140 | streamType: newItem 141 | ttlKey: expirationTime 142 | encrypted: true 143 | pointInTimeRecovery: true 144 | tags: 145 | STAGE: test 146 | TEAM: backend 147 | template: 148 | # Override the computed CF template 149 | Properties: 150 | ProvisionedThroughput: 151 | ReadCapacityUnits : 1 152 | ``` 153 | 154 | ### DynamoDB 155 | 156 | ##### Type: `dynamo` 157 | 158 | _Note that DynamoDB tables default to using [on-demand billing mode][link-dynamo-on-demand-billing]_. 159 | 160 | #### Options 161 | 162 | | Property | Default Value | Description | 163 | |--------------|----------|-------------| 164 | | deploymentBatchSize | `10` | The deployment batch size. Do not exceed the [AWS limits][link-dynamo-deployment-limit] | 165 | 166 | #### Properties: 167 | 168 | | Property | Required | Description | 169 | |--------------|----------|-------------| 170 | | partitionKey | **true** | The partition key. Refer to [keys](#dynamo-keys) | 171 | | sortKey | *false* | The sort key. Refer to [keys](#dynamo-keys) | 172 | | readUnits | *false* [1](#footnote-dynamo-provisioned-units) | The provisioned read units. Setting this changes the table to [provisioned][link-dynamo-provisioned-billing] billing mode. | 173 | | writeUnits | *false* [1](#footnote-dynamo-provisioned-units) | The provisioned write units. Setting this changes the table to [provisioned][link-dynamo-provisioned-billing] billing mode. | 174 | | indexes | *false* | List of secondary [indexes](#dynamo-indexes) | 175 | | streamType | *false* | The [stream type][link-dynamo-stream-types] of the table. See [Stream Types](#dynamo-stream-types) for valid values. | 176 | | ttlKey | *false* | The [Time To Live][link-dynamo-ttl] field | 177 | | encrypted | *false* | Enable [encryption][link-dynamo-encryption] | 178 | | pointInTimeRecovery | *false* | Enable [Point-in-Time Recovery][link-dynamo-recovery] | 179 | | tags | *false* | Key-value pairs of [resource tags][link-dynamo-tags] | 180 | 181 | [1]: Both read and write units are required if one is defined 182 | 183 | #### Keys: 184 | 185 | Keys can be a `string` or an `object`. If a string is provided, then that will be the key name and it will be of data type `string`. 186 | 187 | | Property | Required | Description | 188 | |--------------|----------|-------------| 189 | | name | **true** | The name of the key | 190 | | type | **true** | The [data type](#dynamo-data-types) of the key | 191 | 192 | #### Data Types: 193 | 194 | _Corresponds to [DynamoDB Data Types][link-dynamo-data-types]_ 195 | 196 | | Value | Description | 197 | |--------------|-------------| 198 | | `string` | String | 199 | | `number` | Number | 200 | | `binary` | Binary | 201 | | `boolean` | Boolean | 202 | | `list` | List | 203 | | `map` | Map | 204 | | `numberSet` | Number Set | 205 | | `stringSet` | String Set | 206 | | `binarySet` | Binary Set | 207 | | `null` | Null | 208 | 209 | #### Indexes: 210 | 211 | Indexes can be [Global][link-dynamo-gsi] or [Local][link-dynamo-lsi] indexes. The difference being that Local indexes share the same partition key as the table. Therefore, to create a Local index, just omit the `partitionKey` field. 212 | 213 | | Property | Required | Description | 214 | |--------------|----------|-------------| 215 | | name | **true** | The name of the index | 216 | | partitionKey | *false* [2](#footnote-dynamo-index-key) | The partition key. Refer to [keys](#keys) | 217 | | sortKey | *false* [2](#footnote-dynamo-index-key) | The sort key. Refer to [keys](#keys) | 218 | | readUnits | *false* [3](#footnote-dynamo-index-units) | The provisioned read units | 219 | | writeUnits | *false* [3](#footnote-dynamo-index-units) | The provisioned write units | 220 | | projection | *false* | The [projected fields][link-dynamo-index-projection]. Possible values include:
`all` - **[Default]** The entire record
`keys` - Only keys
A list of fields | 221 | 222 | [2]: At least one key is required 223 | 224 | [3]: Required if defined for the table 225 | 226 | #### Stream Types: 227 | 228 | _Corresponds to [DynamoDB Stream Types][link-dynamo-stream-types]_ 229 | 230 | | Value | Description | 231 | |--------------|-------------| 232 | | `newItem` | Enable stream with new item/image only | 233 | | `oldItem` | Enable stream with old item/image only | 234 | | `both` | Enable stream with new and old items | 235 | | `keys` | Enable stream with keys only | 236 | 237 | ## Others 238 | 239 | If your provider or database isn't supported, [open an issue to request it!][link-open-issue] 240 | 241 | [icon-serverless]: http://public.serverless.com/badges/v3.svg 242 | [icon-license]: https://img.shields.io/github/license/chris-feist/serverless-plugin-tables.svg 243 | [icon-npm-total-downloads]: https://img.shields.io/npm/dt/serverless-plugin-tables.svg 244 | [icon-npm-version]: https://img.shields.io/npm/v/serverless-plugin-tables.svg 245 | [icon-npm-license]: https://img.shields.io/npm/l/serverless-plugin-tables.svg 246 | [icon-build-status]: https://travis-ci.com/chris-feist/serverless-plugin-tables.svg?branch=master 247 | [icon-coverage]: https://img.shields.io/codecov/c/github/chris-feist/serverless-plugin-tables/master.svg 248 | 249 | [link-serverless]: http://www.serverless.com/ 250 | [link-license]: ./LICENSE 251 | [link-npm]: https://www.npmjs.com/package/serverless-plugin-tables 252 | [link-build]: https://travis-ci.com/chris-feist/serverless-plugin-tables 253 | [link-coverage]: https://codecov.io/gh/chris-feist/serverless-plugin-tables 254 | [link-dynamo-deployment-limit]: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Limits.html#limits-api 255 | [link-open-issue]: https://github.com/chris-feist/serverless-plugin-tables/issues 256 | [link-dynamo-data-types]: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.NamingRulesDataTypes.html#HowItWorks.DataTypes 257 | [link-dynamo-on-demand-billing]: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.ReadWriteCapacityMode.html#HowItWorks.OnDemand 258 | [link-dynamo-provisioned-billing]: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.ReadWriteCapacityMode.html#HowItWorks.ProvisionedThroughput.Manual 259 | [link-dynamo-stream-types]: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html 260 | [link-dynamo-ttl]: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/TTL.html 261 | [link-dynamo-encryption]: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/EncryptionAtRest.html 262 | [link-dynamo-recovery]: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/PointInTimeRecovery.html 263 | [link-dynamo-tags]: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Tagging.html 264 | [link-dynamo-gsi]: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GSI.html 265 | [link-dynamo-lsi]: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/LSI.html 266 | [link-dynamo-index-projection]: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/SecondaryIndexes.html 267 | -------------------------------------------------------------------------------- /examples/aws/.serverless_plugins/serverless-plugin-tables.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../../../lib'); 2 | -------------------------------------------------------------------------------- /examples/aws/handler.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 'use strict'; 3 | 4 | module.exports.hello = async (event, context) => { 5 | return { 6 | statusCode: 200, 7 | body: JSON.stringify({ 8 | message: 'Go Serverless v1.0! Your function executed successfully!', 9 | input: event, 10 | }), 11 | }; 12 | 13 | // Use this code if you don't use the http event with the LAMBDA-PROXY integration 14 | // return { message: 'Go Serverless v1.0! Your function executed successfully!', event }; 15 | }; 16 | -------------------------------------------------------------------------------- /examples/aws/serverless.yml: -------------------------------------------------------------------------------- 1 | service: serverless-plugin-tables-example 2 | 3 | plugins: 4 | - serverless-plugin-tables 5 | 6 | provider: 7 | name: aws 8 | runtime: nodejs8.10 9 | region: us-west-2 10 | 11 | custom: 12 | tables: 13 | dynamo: 14 | # Dynamo options 15 | deploymentBatchSize: 5 16 | 17 | functions: 18 | hello: 19 | handler: handler.hello 20 | 21 | resources: 22 | tables: 23 | # Simple DynamoDB Table 24 | Music: 25 | partitionKey: Artist 26 | sortKey: SongTitle 27 | indexes: 28 | - name: GenreByTitleIndex 29 | partitionKey: Genre 30 | sortKey: AlbumTitle 31 | 32 | # Complex DynamoDB Table 33 | People: 34 | resourceName: FavoritePeopleDynamoTable 35 | partitionKey: personID 36 | sortKey: state 37 | readUnits: 5 38 | writeUnits: 5 39 | indexes: 40 | # Global Secondary Index 41 | - name: EmailIndex 42 | partitionKey: email 43 | readUnits: 2 44 | writeUnits: 2 45 | projection: all 46 | # Local Secondary Index 47 | - name: PersonByCreatedTimeIndex 48 | sortKey: 49 | name: createdTime 50 | type: number 51 | readUnits: 2 52 | writeUnits: 2 53 | projection: keys 54 | # Local Secondary Index with projection 55 | - name: PersonByAgeIndex 56 | sortKey: 57 | name: age 58 | type: number 59 | readUnits: 2 60 | writeUnits: 2 61 | projection: 62 | - dob 63 | - firstName 64 | - lastName 65 | streamType: newItem 66 | ttlKey: expirationTime 67 | encrypted: true 68 | pointInTimeRecovery: true 69 | tags: 70 | STAGE: test 71 | TEAM: backend 72 | template: 73 | # Override the computed CF template 74 | Properties: 75 | ProvisionedThroughput: 76 | ReadCapacityUnits : 1 77 | -------------------------------------------------------------------------------- /examples/dynamic-table-names/.serverless_plugins/serverless-plugin-tables.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../../../lib'); 2 | -------------------------------------------------------------------------------- /examples/dynamic-table-names/handler.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 'use strict'; 3 | 4 | module.exports.hello = async (event, context) => { 5 | return { 6 | statusCode: 200, 7 | body: JSON.stringify({ 8 | message: 'Go Serverless v1.0! Your function executed successfully!', 9 | input: event, 10 | }), 11 | }; 12 | 13 | // Use this code if you don't use the http event with the LAMBDA-PROXY integration 14 | // return { message: 'Go Serverless v1.0! Your function executed successfully!', event }; 15 | }; 16 | -------------------------------------------------------------------------------- /examples/dynamic-table-names/serverless.yml: -------------------------------------------------------------------------------- 1 | service: serverless-plugin-tables-example 2 | 3 | plugins: 4 | - serverless-plugin-tables 5 | 6 | provider: 7 | name: aws 8 | runtime: nodejs8.10 9 | region: us-west-2 10 | stage: beta 11 | 12 | custom: 13 | musicTableName: MyMusicTable 14 | 15 | functions: 16 | hello: 17 | handler: handler.hello 18 | 19 | resources: 20 | tables: 21 | Music: 22 | name: ${self:custom.musicTableName} 23 | partitionKey: Artist 24 | sortKey: SongTitle 25 | 26 | Users: 27 | name: Users-${self:provider.stage} 28 | partitionKey: ArtistID 29 | 30 | Venue: 31 | name: ${env:VENUE_TABLE_NAME} 32 | partitionKey: City 33 | -------------------------------------------------------------------------------- /examples/split-resources/.serverless_plugins/serverless-plugin-tables.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../../../lib'); 2 | -------------------------------------------------------------------------------- /examples/split-resources/handler.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 'use strict'; 3 | 4 | module.exports.hello = async (event, context) => { 5 | return { 6 | statusCode: 200, 7 | body: JSON.stringify({ 8 | message: 'Go Serverless v1.0! Your function executed successfully!', 9 | input: event, 10 | }), 11 | }; 12 | 13 | // Use this code if you don't use the http event with the LAMBDA-PROXY integration 14 | // return { message: 'Go Serverless v1.0! Your function executed successfully!', event }; 15 | }; 16 | -------------------------------------------------------------------------------- /examples/split-resources/music-tables.yml: -------------------------------------------------------------------------------- 1 | tables: 2 | Music: 3 | partitionKey: Artist 4 | sortKey: SongTitle 5 | -------------------------------------------------------------------------------- /examples/split-resources/serverless.yml: -------------------------------------------------------------------------------- 1 | service: serverless-plugin-tables-example 2 | 3 | plugins: 4 | - serverless-plugin-tables 5 | 6 | provider: 7 | name: aws 8 | runtime: nodejs8.10 9 | region: us-west-2 10 | 11 | functions: 12 | hello: 13 | handler: handler.hello 14 | 15 | resources: 16 | - ${file(./music-tables.yml)} 17 | - ${file(./users-tables.yml)} 18 | -------------------------------------------------------------------------------- /examples/split-resources/users-tables.yml: -------------------------------------------------------------------------------- 1 | tables: 2 | Users: 3 | partitionKey: ArtistID 4 | -------------------------------------------------------------------------------- /jest.base.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: [ 3 | 'js', 4 | ], 5 | moduleDirectories: [ 6 | 'node_modules', 7 | ], 8 | testEnvironment: 'node', 9 | testPathIgnorePatterns: [ 10 | '/node_modules/', 11 | '/.vscode/', 12 | '/.history/', 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const base = require('./jest.base.config'); 2 | 3 | module.exports = { 4 | ...base, 5 | testMatch: [ 6 | '**/?(*.)+(test)\\.js?(x)', 7 | ], 8 | testPathIgnorePatterns: [ 9 | ...base.testPathIgnorePatterns, 10 | '/test/', 11 | ], 12 | timers: 'fake', 13 | collectCoverage: true, 14 | coverageDirectory: './coverage/jest', 15 | coverageThreshold: { 16 | global: { 17 | statements: 90, 18 | branches: 90, 19 | functions: 90, 20 | lines: 90, 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /jest.integration.config.js: -------------------------------------------------------------------------------- 1 | const base = require('./jest.base.config'); 2 | 3 | module.exports = { 4 | ...base, 5 | rootDir: './test', 6 | }; 7 | -------------------------------------------------------------------------------- /lib/.npmignore: -------------------------------------------------------------------------------- 1 | __tests__ 2 | __mocks__ 3 | -------------------------------------------------------------------------------- /lib/__tests__/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`TablesPlugin initializes plugin 1`] = ` 4 | Object { 5 | "tables": Object { 6 | "commands": Object { 7 | "process": Object { 8 | "lifecycleEvents": Array [ 9 | "process", 10 | ], 11 | "usage": "Process table definitions", 12 | }, 13 | }, 14 | "lifecycleEvents": Array [ 15 | "tables", 16 | ], 17 | "options": Object { 18 | "debug": Object { 19 | "shortcut": "d", 20 | "usage": "Debug the plugin", 21 | }, 22 | "verbose": Object { 23 | "shortcut": "v", 24 | "usage": "Verbose output", 25 | }, 26 | }, 27 | "usage": "Easily manage table resources", 28 | }, 29 | } 30 | `; 31 | 32 | exports[`TablesPlugin initializes plugin 2`] = ` 33 | Object { 34 | "before:package:createDeploymentArtifacts": [Function], 35 | "tables:process:process": [Function], 36 | } 37 | `; 38 | -------------------------------------------------------------------------------- /lib/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | const TablesPlugin = require('../index'); 2 | const aws = require('../aws'); 3 | 4 | jest.mock('../aws'); 5 | 6 | aws.dynamo = { 7 | tableSteps: [ 8 | jest.fn(), 9 | ], 10 | postProcessSteps: [ 11 | jest.fn(), 12 | ], 13 | }; 14 | 15 | const createServerless = () => ({ 16 | cli: { 17 | consoleLog: jest.fn(), 18 | }, 19 | pluginManager: { 20 | spawn: jest.fn(), 21 | }, 22 | service: { 23 | provider: { 24 | name: 'aws', 25 | }, 26 | }, 27 | }); 28 | 29 | beforeEach(() => { 30 | jest.clearAllMocks(); 31 | }); 32 | 33 | describe('TablesPlugin', () => { 34 | test('initializes plugin', () => { 35 | const serverless = createServerless(); 36 | const options = {}; 37 | 38 | const result = new TablesPlugin(serverless, options); 39 | 40 | expect(result).toBeDefined(); 41 | expect(result.serverless).toEqual(serverless); 42 | expect(result.options).toEqual(options); 43 | expect(result.service).toEqual(serverless.service); 44 | expect(result.commands).toMatchSnapshot(); 45 | expect(result.hooks).toMatchSnapshot(); 46 | expect(aws.checkPluginCompatibility).toHaveBeenCalled(); 47 | }); 48 | 49 | test('initializes option flags', () => { 50 | const serverless = createServerless(); 51 | const options = { 52 | d: true, 53 | v: true, 54 | }; 55 | 56 | const result = new TablesPlugin(serverless, options); 57 | 58 | expect(result).toBeDefined(); 59 | expect(result.isDebug).toBe(true); 60 | expect(result.isVerbose).toBe(true); 61 | }); 62 | 63 | test('initializes option names', () => { 64 | const serverless = createServerless(); 65 | const options = { 66 | debug: true, 67 | verbose: true, 68 | }; 69 | 70 | const result = new TablesPlugin(serverless, options); 71 | 72 | expect(result).toBeDefined(); 73 | expect(result.isDebug).toBe(true); 74 | expect(result.isVerbose).toBe(true); 75 | }); 76 | 77 | test('initialize with unsupported provider', () => { 78 | const serverless = createServerless(); 79 | serverless.service.provider.name = 'google'; 80 | const options = {}; 81 | 82 | expect(() => new TablesPlugin(serverless, options)).toThrow(); 83 | }); 84 | 85 | test('log', () => { 86 | const serverless = createServerless(); 87 | const options = {}; 88 | const plugin = new TablesPlugin(serverless, options); 89 | 90 | plugin.log('test-message1', 'test-message2'); 91 | 92 | expect(serverless.cli.consoleLog).toHaveBeenCalled(); 93 | expect(serverless.cli.consoleLog.mock.calls[0][0]).toEqual( 94 | 'serverless-plugin-tables: test-message1 test-message2', 95 | ); 96 | }); 97 | 98 | test('hook: process', () => { 99 | const serverless = createServerless(); 100 | const options = { 101 | verbose: true, 102 | }; 103 | const plugin = new TablesPlugin(serverless, options); 104 | 105 | plugin.hooks[`${TablesPlugin.PLUGIN_NAME}:process:process`](); 106 | 107 | expect(serverless.cli.consoleLog).toHaveBeenCalledWith(expect.stringMatching(/.*Processing tables\.\.\..*/)); 108 | }); 109 | 110 | test('hook: before:package:createDeploymentArtifacts', () => { 111 | const serverless = createServerless(); 112 | const options = {}; 113 | const plugin = new TablesPlugin(serverless, options); 114 | 115 | plugin.hooks['before:package:createDeploymentArtifacts'](); 116 | 117 | expect(serverless.pluginManager.spawn).toHaveBeenCalledWith( 118 | `${TablesPlugin.PLUGIN_NAME}:process`, 119 | ); 120 | }); 121 | 122 | describe('verbose', () => { 123 | test('disabled', () => { 124 | const serverless = createServerless(); 125 | const options = {}; 126 | const plugin = new TablesPlugin(serverless, options); 127 | 128 | plugin.verbose('test-message1'); 129 | 130 | expect(serverless.cli.consoleLog).not.toHaveBeenCalled(); 131 | }); 132 | 133 | test('enabled', () => { 134 | const serverless = createServerless(); 135 | const options = { 136 | verbose: true, 137 | }; 138 | const plugin = new TablesPlugin(serverless, options); 139 | 140 | plugin.verbose('test-message1'); 141 | 142 | expect(serverless.cli.consoleLog).toHaveBeenCalled(); 143 | }); 144 | 145 | test('debug enabled', () => { 146 | const serverless = createServerless(); 147 | const options = { 148 | debug: true, 149 | }; 150 | const plugin = new TablesPlugin(serverless, options); 151 | 152 | plugin.verbose('test-message1'); 153 | 154 | expect(serverless.cli.consoleLog).toHaveBeenCalled(); 155 | }); 156 | }); 157 | 158 | describe('debug', () => { 159 | test('disabled', () => { 160 | const serverless = createServerless(); 161 | const options = {}; 162 | const plugin = new TablesPlugin(serverless, options); 163 | 164 | plugin.debug('test-message1'); 165 | 166 | expect(serverless.cli.consoleLog).not.toHaveBeenCalled(); 167 | }); 168 | 169 | test('enabled', () => { 170 | const serverless = createServerless(); 171 | const options = { 172 | debug: true, 173 | }; 174 | const plugin = new TablesPlugin(serverless, options); 175 | 176 | plugin.debug('test-message1'); 177 | 178 | expect(serverless.cli.consoleLog).toHaveBeenCalled(); 179 | }); 180 | }); 181 | 182 | test('spawn', () => { 183 | const serverless = createServerless(); 184 | const options = {}; 185 | const plugin = new TablesPlugin(serverless, options); 186 | 187 | plugin.spawn('test-command'); 188 | 189 | expect(serverless.pluginManager.spawn).toHaveBeenCalledWith( 190 | `${TablesPlugin.PLUGIN_NAME}:test-command`, 191 | ); 192 | }); 193 | 194 | describe('getOptions', () => { 195 | test('no custom variables', () => { 196 | const serverless = createServerless(); 197 | const options = {}; 198 | const plugin = new TablesPlugin(serverless, options); 199 | 200 | const result = plugin.getOptions(); 201 | 202 | expect(result).toEqual({}); 203 | }); 204 | 205 | test('gets options', () => { 206 | const expected = { 207 | opt1: 'test1', 208 | opt2: 'test2', 209 | }; 210 | const serverless = createServerless(); 211 | serverless.service.custom = { 212 | tables: expected, 213 | }; 214 | const options = {}; 215 | const plugin = new TablesPlugin(serverless, options); 216 | 217 | const result = plugin.getOptions(); 218 | 219 | expect(result).toEqual(expected); 220 | }); 221 | }); 222 | 223 | test('gets provider definition', () => { 224 | const serverless = createServerless(); 225 | const options = {}; 226 | const plugin = new TablesPlugin(serverless, options); 227 | 228 | const result = plugin.getProviderDefinition(); 229 | 230 | expect(result).toBe(aws); 231 | }); 232 | 233 | test('runTableSteps', () => { 234 | const serverless = createServerless(); 235 | const options = {}; 236 | const plugin = new TablesPlugin(serverless, options); 237 | const tableSteps = [ 238 | jest.fn(() => 'step1-complete'), 239 | jest.fn(() => 'step2-complete'), 240 | jest.fn(() => 'step3-complete'), 241 | ]; 242 | const table = { name: 'TestTable' }; 243 | 244 | const result = plugin.runTableSteps(tableSteps, table); 245 | 246 | expect(result).toEqual('step3-complete'); 247 | expect(tableSteps[0]).toHaveBeenCalledWith(plugin, table, null); 248 | expect(tableSteps[1]).toHaveBeenCalledWith(plugin, table, 'step1-complete'); 249 | expect(tableSteps[2]).toHaveBeenCalledWith(plugin, table, 'step2-complete'); 250 | }); 251 | 252 | test('runPostProcessSteps', () => { 253 | const serverless = createServerless(); 254 | const options = {}; 255 | const plugin = new TablesPlugin(serverless, options); 256 | const postProcessSteps = [ 257 | jest.fn(() => 'step1-complete'), 258 | jest.fn(() => 'step2-complete'), 259 | jest.fn(() => 'step3-complete'), 260 | ]; 261 | const tables = [{ name: 'TestTable' }]; 262 | const processedTables = [{ name: 'ProcessedTestTable' }]; 263 | 264 | const result = plugin.runPostProcessSteps(postProcessSteps, tables, processedTables); 265 | 266 | expect(result).toEqual('step3-complete'); 267 | expect(postProcessSteps[0]).toHaveBeenCalledWith(plugin, tables, processedTables); 268 | expect(postProcessSteps[1]).toHaveBeenCalledWith(plugin, tables, 'step1-complete'); 269 | expect(postProcessSteps[2]).toHaveBeenCalledWith(plugin, tables, 'step2-complete'); 270 | }); 271 | 272 | describe('process', () => { 273 | test('no resources defined', () => { 274 | const serverless = createServerless(); 275 | const options = { 276 | verbose: true, 277 | }; 278 | const plugin = new TablesPlugin(serverless, options); 279 | 280 | plugin.process(); 281 | 282 | expect(serverless.cli.consoleLog).toHaveBeenCalledWith(expect.stringMatching(/.*No tables to process.*/)); 283 | }); 284 | 285 | test('no tables defined', () => { 286 | const serverless = createServerless(); 287 | Object.assign(serverless.service, { 288 | resources: { 289 | tables: null, 290 | }, 291 | }); 292 | const options = { 293 | verbose: true, 294 | }; 295 | const plugin = new TablesPlugin(serverless, options); 296 | 297 | plugin.process(); 298 | 299 | expect(serverless.cli.consoleLog).toHaveBeenCalledWith(expect.stringMatching(/.*No tables to process.*/)); 300 | }); 301 | 302 | test('processes tables', () => { 303 | const serverless = createServerless(); 304 | Object.assign(serverless.service, { 305 | resources: { 306 | tables: { 307 | Table1: { 308 | type: 'dynamo', 309 | }, 310 | Table2: { 311 | type: 'dynamo', 312 | }, 313 | }, 314 | }, 315 | }); 316 | const options = { }; 317 | const plugin = new TablesPlugin(serverless, options); 318 | 319 | plugin.process(); 320 | 321 | expect(aws.dynamo.tableSteps[0]).toHaveBeenCalledTimes(2); 322 | expect(aws.dynamo.postProcessSteps[0]).toHaveBeenCalledTimes(1); 323 | }); 324 | 325 | test('unknown table type', () => { 326 | const serverless = createServerless(); 327 | Object.assign(serverless.service, { 328 | resources: { 329 | tables: { 330 | Table1: { 331 | type: 'dynamo', 332 | }, 333 | UnknownTable: { 334 | type: 'sql', 335 | }, 336 | }, 337 | }, 338 | }); 339 | const options = { debug: true }; 340 | const plugin = new TablesPlugin(serverless, options); 341 | 342 | expect(() => plugin.process()).toThrow(); 343 | }); 344 | }); 345 | }); 346 | -------------------------------------------------------------------------------- /lib/aws/__tests__/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`aws index checkPluginCompatibility serverless-dynamodb-local hooks spawn process 1`] = ` 4 | Array [ 5 | Array [ 6 | "process", 7 | ], 8 | Array [ 9 | "process", 10 | ], 11 | Array [ 12 | "process", 13 | ], 14 | ] 15 | `; 16 | -------------------------------------------------------------------------------- /lib/aws/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | const { checkPluginCompatibility } = require('../index'); 2 | const { PACKAGE_NAME } = require('../../index'); 3 | 4 | const createPlugin = (plugins = [PACKAGE_NAME]) => ({ 5 | debug: jest.fn(), 6 | log: jest.fn(), 7 | spawn: jest.fn(), 8 | service: { 9 | plugins, 10 | }, 11 | hooks: { 12 | 'before:package:createDeploymentArtifacts': jest.fn(), 13 | }, 14 | }); 15 | 16 | beforeEach(() => { 17 | jest.clearAllMocks(); 18 | }); 19 | 20 | describe('aws index', () => { 21 | describe('checkPluginCompatibility', () => { 22 | test('no other plugins', () => { 23 | const plugin = createPlugin(); 24 | 25 | checkPluginCompatibility(plugin); 26 | 27 | expect(plugin.log).not.toHaveBeenCalled(); 28 | expect(Object.keys(plugin.hooks)).toHaveLength(1); 29 | expect(plugin.hooks).toHaveProperty('before:package:createDeploymentArtifacts'); 30 | }); 31 | 32 | test('serverless-dynamodb-local before self', () => { 33 | const plugin = createPlugin([ 34 | 'serverless-dynamodb-local', 35 | PACKAGE_NAME, 36 | ]); 37 | 38 | checkPluginCompatibility(plugin); 39 | 40 | expect(plugin.log).toHaveBeenCalled(); 41 | expect(Object.keys(plugin.hooks)).toHaveLength(4); 42 | expect(plugin.hooks).toHaveProperty('before:package:createDeploymentArtifacts'); 43 | expect(plugin.hooks).toHaveProperty('before:dynamodb:migrate:migrateHandler'); 44 | expect(plugin.hooks).toHaveProperty('before:dynamodb:start:startHandler'); 45 | expect(plugin.hooks).toHaveProperty('before:offline:start:init'); 46 | }); 47 | 48 | test('serverless-dynamodb-local after self', () => { 49 | const plugin = createPlugin([ 50 | PACKAGE_NAME, 51 | 'serverless-dynamodb-local', 52 | ]); 53 | 54 | checkPluginCompatibility(plugin); 55 | 56 | expect(plugin.log).not.toHaveBeenCalled(); 57 | expect(Object.keys(plugin.hooks)).toHaveLength(4); 58 | expect(plugin.hooks).toHaveProperty('before:package:createDeploymentArtifacts'); 59 | expect(plugin.hooks).toHaveProperty('before:dynamodb:migrate:migrateHandler'); 60 | expect(plugin.hooks).toHaveProperty('before:dynamodb:start:startHandler'); 61 | expect(plugin.hooks).toHaveProperty('before:offline:start:init'); 62 | }); 63 | 64 | test('serverless-dynamodb-local hooks spawn process', () => { 65 | const plugin = createPlugin([ 66 | PACKAGE_NAME, 67 | 'serverless-dynamodb-local', 68 | ]); 69 | 70 | checkPluginCompatibility(plugin); 71 | 72 | plugin.hooks['before:dynamodb:migrate:migrateHandler'](); 73 | plugin.hooks['before:dynamodb:start:startHandler'](); 74 | plugin.hooks['before:offline:start:init'](); 75 | 76 | expect(plugin.spawn).toHaveBeenCalledTimes(3); 77 | expect(plugin.spawn.mock.calls).toMatchSnapshot(); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /lib/aws/dynamo/__tests__/__snapshots__/addToResources.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`addToResources adds to resources 1`] = ` 4 | Object { 5 | "Resources": Object { 6 | "Res1": Object { 7 | "Properties": Object { 8 | "TableName": "Table1", 9 | }, 10 | "Type": "AWS::DynamoDB::Table", 11 | }, 12 | "Res2": Object { 13 | "Properties": Object { 14 | "TableName": "Table2", 15 | }, 16 | "Type": "AWS::DynamoDB::Table", 17 | }, 18 | "Res3": Object { 19 | "Properties": Object { 20 | "TableName": "Table3", 21 | }, 22 | "Type": "AWS::DynamoDB::Table", 23 | }, 24 | "Res4": Object { 25 | "Properties": Object { 26 | "TableName": "Table4", 27 | }, 28 | "Type": "AWS::DynamoDB::Table", 29 | }, 30 | }, 31 | } 32 | `; 33 | 34 | exports[`addToResources initializes resources 1`] = ` 35 | Object { 36 | "Resources": Object { 37 | "TestResource": Object { 38 | "Properties": Object { 39 | "TableName": "TestTable", 40 | }, 41 | "Type": "AWS::DynamoDB::Table", 42 | }, 43 | }, 44 | } 45 | `; 46 | -------------------------------------------------------------------------------- /lib/aws/dynamo/__tests__/__snapshots__/createProperties.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`createProperties Global Secondary Indexes 1`] = ` 4 | Array [ 5 | Object { 6 | "IndexName": "OnlyPartitionGsi", 7 | "KeySchema": Array [ 8 | Object { 9 | "AttributeName": "only-partition", 10 | "KeyType": "HASH", 11 | }, 12 | ], 13 | "Projection": Object { 14 | "ProjectionType": "ALL", 15 | }, 16 | }, 17 | Object { 18 | "IndexName": "OnlyKeysGsi", 19 | "KeySchema": Array [ 20 | Object { 21 | "AttributeName": "both-partition", 22 | "KeyType": "HASH", 23 | }, 24 | Object { 25 | "AttributeName": "both-sort", 26 | "KeyType": "RANGE", 27 | }, 28 | ], 29 | "Projection": Object { 30 | "ProjectionType": "ALL", 31 | }, 32 | }, 33 | Object { 34 | "IndexName": "ComplexKeysGsi", 35 | "KeySchema": Array [ 36 | Object { 37 | "AttributeName": "complex-partition", 38 | "KeyType": "HASH", 39 | }, 40 | Object { 41 | "AttributeName": "complex-sort", 42 | "KeyType": "RANGE", 43 | }, 44 | ], 45 | "Projection": Object { 46 | "ProjectionType": "ALL", 47 | }, 48 | }, 49 | Object { 50 | "IndexName": "ProvisionedGsi", 51 | "KeySchema": Array [ 52 | Object { 53 | "AttributeName": "provisioned-partition", 54 | "KeyType": "HASH", 55 | }, 56 | ], 57 | "Projection": Object { 58 | "ProjectionType": "ALL", 59 | }, 60 | "ProvisionedThroughput": Object { 61 | "ReadCapacityUnits": 5, 62 | "WriteCapacityUnits": 1, 63 | }, 64 | }, 65 | Object { 66 | "IndexName": "KeysProjectedGsi", 67 | "KeySchema": Array [ 68 | Object { 69 | "AttributeName": "projects-keys-partition", 70 | "KeyType": "HASH", 71 | }, 72 | ], 73 | "Projection": Object { 74 | "ProjectionType": "KEYS_ONLY", 75 | }, 76 | }, 77 | Object { 78 | "IndexName": "IncludesProjectionGsi", 79 | "KeySchema": Array [ 80 | Object { 81 | "AttributeName": "projects-includes-partition", 82 | "KeyType": "HASH", 83 | }, 84 | ], 85 | "Projection": Object { 86 | "NonKeyAttributes": Array [ 87 | "field1", 88 | "field2", 89 | ], 90 | "ProjectionType": "INCLUDE", 91 | }, 92 | }, 93 | ] 94 | `; 95 | 96 | exports[`createProperties Local Secondary Indexes 1`] = ` 97 | Array [ 98 | Object { 99 | "IndexName": "OnlyKeysLsi", 100 | "KeySchema": Array [ 101 | Object { 102 | "AttributeName": "id", 103 | "KeyType": "HASH", 104 | }, 105 | Object { 106 | "AttributeName": "only-sort", 107 | "KeyType": "RANGE", 108 | }, 109 | ], 110 | "Projection": Object { 111 | "ProjectionType": "ALL", 112 | }, 113 | }, 114 | Object { 115 | "IndexName": "ComplexKeysLsi", 116 | "KeySchema": Array [ 117 | Object { 118 | "AttributeName": "id", 119 | "KeyType": "HASH", 120 | }, 121 | Object { 122 | "AttributeName": "complex-sort", 123 | "KeyType": "RANGE", 124 | }, 125 | ], 126 | "Projection": Object { 127 | "ProjectionType": "ALL", 128 | }, 129 | }, 130 | Object { 131 | "IndexName": "ProvisionedLsi", 132 | "KeySchema": Array [ 133 | Object { 134 | "AttributeName": "id", 135 | "KeyType": "HASH", 136 | }, 137 | Object { 138 | "AttributeName": "provisioned-sort", 139 | "KeyType": "RANGE", 140 | }, 141 | ], 142 | "Projection": Object { 143 | "ProjectionType": "ALL", 144 | }, 145 | "ProvisionedThroughput": Object { 146 | "ReadCapacityUnits": 5, 147 | "WriteCapacityUnits": 1, 148 | }, 149 | }, 150 | Object { 151 | "IndexName": "KeysProjectedLsi", 152 | "KeySchema": Array [ 153 | Object { 154 | "AttributeName": "id", 155 | "KeyType": "HASH", 156 | }, 157 | Object { 158 | "AttributeName": "projects-keys-sort", 159 | "KeyType": "RANGE", 160 | }, 161 | ], 162 | "Projection": Object { 163 | "ProjectionType": "KEYS_ONLY", 164 | }, 165 | }, 166 | Object { 167 | "IndexName": "IncludesProjectionLsi", 168 | "KeySchema": Array [ 169 | Object { 170 | "AttributeName": "id", 171 | "KeyType": "HASH", 172 | }, 173 | Object { 174 | "AttributeName": "projects-includes-sort", 175 | "KeyType": "RANGE", 176 | }, 177 | ], 178 | "Projection": Object { 179 | "NonKeyAttributes": Array [ 180 | "field1", 181 | "field2", 182 | ], 183 | "ProjectionType": "INCLUDE", 184 | }, 185 | }, 186 | ] 187 | `; 188 | -------------------------------------------------------------------------------- /lib/aws/dynamo/__tests__/__snapshots__/handleDeploymentLimit.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`handleDeploymentLimit batches tables 1`] = ` 4 | Array [ 5 | Object { 6 | "res3": Object { 7 | "Properties": Object { 8 | "TableName": "TestTable", 9 | }, 10 | "Type": "AWS::DynamoDB::Table", 11 | }, 12 | }, 13 | Object { 14 | "res4": Object { 15 | "Properties": Object { 16 | "TableName": "TestTable", 17 | }, 18 | "Type": "AWS::DynamoDB::Table", 19 | }, 20 | }, 21 | Object { 22 | "res5": Object { 23 | "Properties": Object { 24 | "TableName": "TestTable", 25 | }, 26 | "Type": "AWS::DynamoDB::Table", 27 | }, 28 | }, 29 | Object { 30 | "res6": Object { 31 | "Properties": Object { 32 | "TableName": "TestTable", 33 | }, 34 | "Type": "AWS::DynamoDB::Table", 35 | }, 36 | }, 37 | Object { 38 | "res7": Object { 39 | "Properties": Object { 40 | "TableName": "TestTable", 41 | }, 42 | "Type": "AWS::DynamoDB::Table", 43 | }, 44 | }, 45 | Object { 46 | "res8": Object { 47 | "Properties": Object { 48 | "TableName": "TestTable", 49 | }, 50 | "Type": "AWS::DynamoDB::Table", 51 | }, 52 | }, 53 | Object { 54 | "res9": Object { 55 | "Properties": Object { 56 | "TableName": "TestTable", 57 | }, 58 | "Type": "AWS::DynamoDB::Table", 59 | }, 60 | }, 61 | Object { 62 | "res1": Object { 63 | "DependsOn": Array [ 64 | "res9", 65 | ], 66 | "Properties": Object { 67 | "TableName": "TestTable", 68 | }, 69 | "Type": "AWS::DynamoDB::Table", 70 | }, 71 | }, 72 | Object { 73 | "res10": Object { 74 | "DependsOn": Array [ 75 | "res9", 76 | ], 77 | "Properties": Object { 78 | "TableName": "TestTable", 79 | }, 80 | "Type": "AWS::DynamoDB::Table", 81 | }, 82 | }, 83 | Object { 84 | "res2": Object { 85 | "DependsOn": Array [ 86 | "res1", 87 | "res10", 88 | ], 89 | "Properties": Object { 90 | "TableName": "TestTable", 91 | }, 92 | "Type": "AWS::DynamoDB::Table", 93 | }, 94 | }, 95 | Object { 96 | "res11": Object { 97 | "DependsOn": Array [ 98 | "res10", 99 | ], 100 | "Properties": Object { 101 | "TableName": "TestTable", 102 | }, 103 | "Type": "AWS::DynamoDB::Table", 104 | }, 105 | }, 106 | Object { 107 | "res12": Object { 108 | "DependsOn": Array [ 109 | "res11", 110 | ], 111 | "Properties": Object { 112 | "TableName": "TestTable", 113 | }, 114 | "Type": "AWS::DynamoDB::Table", 115 | }, 116 | }, 117 | ] 118 | `; 119 | 120 | exports[`handleDeploymentLimit createBatch creates a batch assigns depends on 1`] = ` 121 | Array [ 122 | Object { 123 | "res3": Object { 124 | "DependsOn": Array [ 125 | "otherRes", 126 | ], 127 | "Properties": Object { 128 | "TableName": "TestTable", 129 | }, 130 | "Type": "AWS::DynamoDB::Table", 131 | }, 132 | }, 133 | Object { 134 | "res4": Object { 135 | "DependsOn": Array [ 136 | "otherRes", 137 | ], 138 | "Properties": Object { 139 | "TableName": "TestTable", 140 | }, 141 | "Type": "AWS::DynamoDB::Table", 142 | }, 143 | }, 144 | Object { 145 | "res5": Object { 146 | "DependsOn": Array [ 147 | "res999", 148 | "otherRes", 149 | ], 150 | "Properties": Object { 151 | "TableName": "TestTable", 152 | }, 153 | "Type": "AWS::DynamoDB::Table", 154 | }, 155 | }, 156 | ] 157 | `; 158 | 159 | exports[`handleDeploymentLimit createBatch creates a batch assigns depends on 2`] = ` 160 | Array [ 161 | Object { 162 | "res1": Object { 163 | "DependsOn": "res4", 164 | "Properties": Object { 165 | "TableName": "TestTable", 166 | }, 167 | "Type": "AWS::DynamoDB::Table", 168 | }, 169 | }, 170 | Object { 171 | "res2": Object { 172 | "DependsOn": "res5", 173 | "Properties": Object { 174 | "TableName": "TestTable", 175 | }, 176 | "Type": "AWS::DynamoDB::Table", 177 | }, 178 | }, 179 | ] 180 | `; 181 | 182 | exports[`handleDeploymentLimit createBatch creates a batch with internal dependencies 1`] = ` 183 | Array [ 184 | Object { 185 | "res2": Object { 186 | "DependsOn": "res1000", 187 | "Properties": Object { 188 | "TableName": "TestTable", 189 | }, 190 | "Type": "AWS::DynamoDB::Table", 191 | }, 192 | }, 193 | Object { 194 | "res3": Object { 195 | "Properties": Object { 196 | "TableName": "TestTable", 197 | }, 198 | "Type": "AWS::DynamoDB::Table", 199 | }, 200 | }, 201 | Object { 202 | "res4": Object { 203 | "Properties": Object { 204 | "TableName": "TestTable", 205 | }, 206 | "Type": "AWS::DynamoDB::Table", 207 | }, 208 | }, 209 | Object { 210 | "res5": Object { 211 | "Properties": Object { 212 | "TableName": "TestTable", 213 | }, 214 | "Type": "AWS::DynamoDB::Table", 215 | }, 216 | }, 217 | Object { 218 | "res6": Object { 219 | "Properties": Object { 220 | "TableName": "TestTable", 221 | }, 222 | "Type": "AWS::DynamoDB::Table", 223 | }, 224 | }, 225 | ] 226 | `; 227 | 228 | exports[`handleDeploymentLimit createBatch creates a batch with internal dependencies 2`] = ` 229 | Array [ 230 | Object { 231 | "res1": Object { 232 | "DependsOn": "res9", 233 | "Properties": Object { 234 | "TableName": "TestTable", 235 | }, 236 | "Type": "AWS::DynamoDB::Table", 237 | }, 238 | }, 239 | Object { 240 | "res7": Object { 241 | "Properties": Object { 242 | "TableName": "TestTable", 243 | }, 244 | "Type": "AWS::DynamoDB::Table", 245 | }, 246 | }, 247 | Object { 248 | "res8": Object { 249 | "Properties": Object { 250 | "TableName": "TestTable", 251 | }, 252 | "Type": "AWS::DynamoDB::Table", 253 | }, 254 | }, 255 | Object { 256 | "res9": Object { 257 | "Properties": Object { 258 | "TableName": "TestTable", 259 | }, 260 | "Type": "AWS::DynamoDB::Table", 261 | }, 262 | }, 263 | Object { 264 | "res10": Object { 265 | "DependsOn": "res9", 266 | "Properties": Object { 267 | "TableName": "TestTable", 268 | }, 269 | "Type": "AWS::DynamoDB::Table", 270 | }, 271 | }, 272 | Object { 273 | "res11": Object { 274 | "DependsOn": "res10", 275 | "Properties": Object { 276 | "TableName": "TestTable", 277 | }, 278 | "Type": "AWS::DynamoDB::Table", 279 | }, 280 | }, 281 | Object { 282 | "res12": Object { 283 | "DependsOn": "res11", 284 | "Properties": Object { 285 | "TableName": "TestTable", 286 | }, 287 | "Type": "AWS::DynamoDB::Table", 288 | }, 289 | }, 290 | ] 291 | `; 292 | -------------------------------------------------------------------------------- /lib/aws/dynamo/__tests__/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`dynamo default export 1`] = ` 4 | Object { 5 | "postProcessSteps": Array [ 6 | [Function], 7 | [Function], 8 | [Function], 9 | ], 10 | "tableSteps": Array [ 11 | [Function], 12 | [Function], 13 | [Function], 14 | ], 15 | } 16 | `; 17 | 18 | exports[`dynamo end to end 1`] = ` 19 | Object { 20 | "Resources": Object { 21 | "ExistingDynamoDBTable": Object { 22 | "DependsOn": Array [ 23 | "FavoritePeopleDynamoTable", 24 | ], 25 | "Properties": Object { 26 | "AttributeDefinitions": Array [ 27 | Object { 28 | "AttributeName": "userID", 29 | "AttributeType": "S", 30 | }, 31 | Object { 32 | "AttributeName": "email", 33 | "AttributeType": "S", 34 | }, 35 | Object { 36 | "AttributeName": "createdTime", 37 | "AttributeType": "N", 38 | }, 39 | ], 40 | "BillingMode": "PAY_PER_REQUEST", 41 | "KeySchema": Array [ 42 | Object { 43 | "AttributeName": "userID", 44 | "KeyType": "HASH", 45 | }, 46 | Object { 47 | "AttributeName": "email", 48 | "KeyType": "RANGE", 49 | }, 50 | ], 51 | "LocalSecondaryIndexes": Array [ 52 | Object { 53 | "IndexName": "UsersTableEmailGSI", 54 | "KeySchema": Array [ 55 | Object { 56 | "AttributeName": "userID", 57 | "KeyType": "HASH", 58 | }, 59 | Object { 60 | "AttributeName": "createdTime", 61 | "KeyType": "RANGE", 62 | }, 63 | ], 64 | "Projection": Object { 65 | "ProjectionType": "ALL", 66 | }, 67 | }, 68 | ], 69 | "TableName": "ExistingTable", 70 | }, 71 | "Type": "AWS::DynamoDB::Table", 72 | }, 73 | "FavoritePeopleDynamoTable": Object { 74 | "Properties": Object { 75 | "AttributeDefinitions": Array [ 76 | Object { 77 | "AttributeName": "personID", 78 | "AttributeType": "S", 79 | }, 80 | Object { 81 | "AttributeName": "state", 82 | "AttributeType": "S", 83 | }, 84 | Object { 85 | "AttributeName": "email", 86 | "AttributeType": "S", 87 | }, 88 | Object { 89 | "AttributeName": "createdTime", 90 | "AttributeType": "N", 91 | }, 92 | Object { 93 | "AttributeName": "age", 94 | "AttributeType": "N", 95 | }, 96 | ], 97 | "BillingMode": "PROVISIONED", 98 | "GlobalSecondaryIndexes": Array [ 99 | Object { 100 | "IndexName": "EmailIndex", 101 | "KeySchema": Array [ 102 | Object { 103 | "AttributeName": "email", 104 | "KeyType": "HASH", 105 | }, 106 | ], 107 | "Projection": Object { 108 | "ProjectionType": "ALL", 109 | }, 110 | "ProvisionedThroughput": Object { 111 | "ReadCapacityUnits": 2, 112 | "WriteCapacityUnits": 2, 113 | }, 114 | }, 115 | ], 116 | "KeySchema": Array [ 117 | Object { 118 | "AttributeName": "personID", 119 | "KeyType": "HASH", 120 | }, 121 | Object { 122 | "AttributeName": "state", 123 | "KeyType": "RANGE", 124 | }, 125 | ], 126 | "LocalSecondaryIndexes": Array [ 127 | Object { 128 | "IndexName": "PersonByCreatedTimeIndex", 129 | "KeySchema": Array [ 130 | Object { 131 | "AttributeName": "personID", 132 | "KeyType": "HASH", 133 | }, 134 | Object { 135 | "AttributeName": "createdTime", 136 | "KeyType": "RANGE", 137 | }, 138 | ], 139 | "Projection": Object { 140 | "ProjectionType": "KEYS_ONLY", 141 | }, 142 | "ProvisionedThroughput": Object { 143 | "ReadCapacityUnits": 2, 144 | "WriteCapacityUnits": 2, 145 | }, 146 | }, 147 | Object { 148 | "IndexName": "PersonByAgeIndex", 149 | "KeySchema": Array [ 150 | Object { 151 | "AttributeName": "personID", 152 | "KeyType": "HASH", 153 | }, 154 | Object { 155 | "AttributeName": "age", 156 | "KeyType": "RANGE", 157 | }, 158 | ], 159 | "Projection": Object { 160 | "NonKeyAttributes": Array [ 161 | "dob", 162 | "firstName", 163 | "lastName", 164 | ], 165 | "ProjectionType": "INCLUDE", 166 | }, 167 | "ProvisionedThroughput": Object { 168 | "ReadCapacityUnits": 2, 169 | "WriteCapacityUnits": 2, 170 | }, 171 | }, 172 | ], 173 | "PointInTimeRecoverySpecification": Object { 174 | "PointInTimeRecoveryEnabled": true, 175 | }, 176 | "ProvisionedThroughput": Object { 177 | "ReadCapacityUnits": 1, 178 | "WriteCapacityUnits": 5, 179 | }, 180 | "SSESpecification": Object { 181 | "SSEEnabled": true, 182 | }, 183 | "StreamSpecification": Object { 184 | "StreamViewType": "NEW_IMAGE", 185 | }, 186 | "TableName": "People", 187 | "Tags": Array [ 188 | Object { 189 | "Key": "STAGE", 190 | "Value": "test", 191 | }, 192 | Object { 193 | "Key": "TEAM", 194 | "Value": "backend", 195 | }, 196 | ], 197 | "TimeToLiveSpecification": Object { 198 | "AttributeName": "expirationTime", 199 | "Enabled": true, 200 | }, 201 | }, 202 | "Type": "AWS::DynamoDB::Table", 203 | }, 204 | "MusicDynamoDbTable": Object { 205 | "Properties": Object { 206 | "AttributeDefinitions": Array [ 207 | Object { 208 | "AttributeName": "Artist", 209 | "AttributeType": "S", 210 | }, 211 | Object { 212 | "AttributeName": "SongTitle", 213 | "AttributeType": "S", 214 | }, 215 | Object { 216 | "AttributeName": "Genre", 217 | "AttributeType": "S", 218 | }, 219 | Object { 220 | "AttributeName": "AlbumTitle", 221 | "AttributeType": "S", 222 | }, 223 | ], 224 | "BillingMode": "PAY_PER_REQUEST", 225 | "GlobalSecondaryIndexes": Array [ 226 | Object { 227 | "IndexName": "GenreByTitleIndex", 228 | "KeySchema": Array [ 229 | Object { 230 | "AttributeName": "Genre", 231 | "KeyType": "HASH", 232 | }, 233 | Object { 234 | "AttributeName": "AlbumTitle", 235 | "KeyType": "RANGE", 236 | }, 237 | ], 238 | "Projection": Object { 239 | "ProjectionType": "ALL", 240 | }, 241 | }, 242 | ], 243 | "KeySchema": Array [ 244 | Object { 245 | "AttributeName": "Artist", 246 | "KeyType": "HASH", 247 | }, 248 | Object { 249 | "AttributeName": "SongTitle", 250 | "KeyType": "RANGE", 251 | }, 252 | ], 253 | "TableName": "Music", 254 | }, 255 | "Type": "AWS::DynamoDB::Table", 256 | }, 257 | }, 258 | } 259 | `; 260 | -------------------------------------------------------------------------------- /lib/aws/dynamo/__tests__/addToResources.test.js: -------------------------------------------------------------------------------- 1 | const addToResources = require('../addToResources'); 2 | 3 | const { createTableResource } = require('../testUtils'); 4 | 5 | beforeEach(() => { 6 | jest.clearAllMocks(); 7 | }); 8 | 9 | describe('addToResources', () => { 10 | test('adds to resources', () => { 11 | const plugin = { 12 | service: { 13 | resources: {}, 14 | }, 15 | }; 16 | const tables = []; // ignored 17 | const processedTables = [ 18 | createTableResource({ resourceName: 'Res1', tableName: 'Table1' }), 19 | createTableResource({ resourceName: 'Res2', tableName: 'Table2' }), 20 | createTableResource({ resourceName: 'Res3', tableName: 'Table3' }), 21 | createTableResource({ resourceName: 'Res4', tableName: 'Table4' }), 22 | ]; 23 | 24 | addToResources(plugin, tables, processedTables); 25 | 26 | expect(plugin.service.resources.Resources).toBeDefined(); 27 | expect(plugin.service.resources.Resources.Res1).toBeDefined(); 28 | expect(plugin.service.resources).toMatchSnapshot(); 29 | }); 30 | 31 | 32 | test('initializes resources', () => { 33 | const plugin = { 34 | service: { 35 | resources: {}, 36 | }, 37 | }; 38 | const tables = []; // ignored 39 | const processedTables = [createTableResource()]; 40 | 41 | addToResources(plugin, tables, processedTables); 42 | 43 | expect(plugin.service.resources.Resources).toBeDefined(); 44 | expect(plugin.service.resources).toMatchSnapshot(); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /lib/aws/dynamo/__tests__/createProperties.test.js: -------------------------------------------------------------------------------- 1 | const createProperties = require('../createProperties'); 2 | 3 | const createPlugin = () => ({ 4 | debug: jest.fn(), 5 | }); 6 | 7 | beforeEach(() => { 8 | jest.clearAllMocks(); 9 | }); 10 | 11 | describe('createProperties', () => { 12 | test('creates required properties', () => { 13 | const plugin = createPlugin(); 14 | const table = { 15 | name: 'TestTable', 16 | partitionKey: 'id', 17 | }; 18 | 19 | const result = createProperties(plugin, table); 20 | 21 | expect(result).toEqual({ 22 | TableName: table.name, 23 | BillingMode: 'PAY_PER_REQUEST', 24 | KeySchema: [ 25 | { AttributeName: 'id', KeyType: 'HASH' }, 26 | ], 27 | AttributeDefinitions: [ 28 | { AttributeName: 'id', AttributeType: 'S' }, 29 | ], 30 | }); 31 | }); 32 | 33 | test('different key data type', () => { 34 | const plugin = createPlugin(); 35 | const table = { 36 | name: 'TestTable', 37 | partitionKey: { 38 | name: 'id', 39 | type: 'number', 40 | }, 41 | sortKey: { 42 | name: 'deleted', 43 | type: 'boolean', 44 | }, 45 | }; 46 | 47 | const result = createProperties(plugin, table); 48 | 49 | expect(result).toEqual({ 50 | TableName: table.name, 51 | BillingMode: 'PAY_PER_REQUEST', 52 | KeySchema: [ 53 | { AttributeName: 'id', KeyType: 'HASH' }, 54 | { AttributeName: 'deleted', KeyType: 'RANGE' }, 55 | ], 56 | AttributeDefinitions: [ 57 | { AttributeName: 'id', AttributeType: 'N' }, 58 | { AttributeName: 'deleted', AttributeType: 'BOOL' }, 59 | ], 60 | }); 61 | }); 62 | 63 | test('provisioned units', () => { 64 | const plugin = createPlugin(); 65 | const table = { 66 | name: 'TestTable', 67 | partitionKey: 'id', 68 | writeUnits: 1, 69 | readUnits: 5, 70 | }; 71 | 72 | const result = createProperties(plugin, table); 73 | 74 | expect(result.BillingMode).toEqual('PROVISIONED'); 75 | expect(result.ProvisionedThroughput).toEqual({ 76 | ReadCapacityUnits: 5, 77 | WriteCapacityUnits: 1, 78 | }); 79 | }); 80 | 81 | test('tags units', () => { 82 | const plugin = createPlugin(); 83 | const table = { 84 | name: 'TestTable', 85 | partitionKey: 'id', 86 | tags: { 87 | key1: 'value1', 88 | key2: 'value2', 89 | }, 90 | }; 91 | 92 | const result = createProperties(plugin, table); 93 | 94 | expect(result.Tags).toHaveLength(2); 95 | expect(result.Tags).toContainEqual({ 96 | Key: 'key1', 97 | Value: 'value1', 98 | }); 99 | expect(result.Tags).toContainEqual({ 100 | Key: 'key2', 101 | Value: 'value2', 102 | }); 103 | }); 104 | 105 | 106 | test('streams', () => { 107 | const plugin = createPlugin(); 108 | const table = { 109 | name: 'TestTable', 110 | partitionKey: 'id', 111 | streamType: 'newItem', 112 | }; 113 | 114 | const result = createProperties(plugin, table); 115 | 116 | expect(result.StreamSpecification).toEqual({ 117 | StreamViewType: 'NEW_IMAGE', 118 | }); 119 | }); 120 | 121 | test('TTL key', () => { 122 | const plugin = createPlugin(); 123 | const table = { 124 | name: 'TestTable', 125 | partitionKey: 'id', 126 | ttlKey: 'expireTime', 127 | }; 128 | 129 | const result = createProperties(plugin, table); 130 | 131 | expect(result.TimeToLiveSpecification).toEqual({ 132 | AttributeName: 'expireTime', 133 | Enabled: true, 134 | }); 135 | }); 136 | 137 | test('Encrypted', () => { 138 | const plugin = createPlugin(); 139 | const table = { 140 | name: 'TestTable', 141 | partitionKey: 'id', 142 | encrypted: true, 143 | }; 144 | 145 | const result = createProperties(plugin, table); 146 | 147 | expect(result.SSESpecification).toEqual({ 148 | SSEEnabled: true, 149 | }); 150 | }); 151 | 152 | test('Point in type recovery', () => { 153 | const plugin = createPlugin(); 154 | const table = { 155 | name: 'TestTable', 156 | partitionKey: 'id', 157 | pointInTimeRecovery: true, 158 | }; 159 | 160 | const result = createProperties(plugin, table); 161 | 162 | expect(result.PointInTimeRecoverySpecification).toEqual({ 163 | PointInTimeRecoveryEnabled: true, 164 | }); 165 | }); 166 | 167 | test('Global Secondary Indexes', () => { 168 | const plugin = createPlugin(); 169 | const table = { 170 | name: 'TestTable', 171 | partitionKey: 'id', 172 | indexes: [ 173 | { 174 | name: 'OnlyPartitionGsi', 175 | partitionKey: 'only-partition', 176 | }, 177 | { 178 | name: 'OnlyKeysGsi', 179 | partitionKey: 'both-partition', 180 | sortKey: 'both-sort', 181 | }, 182 | { 183 | name: 'ComplexKeysGsi', 184 | partitionKey: { 185 | name: 'complex-partition', 186 | type: 'number', 187 | }, 188 | sortKey: { 189 | name: 'complex-sort', 190 | type: 'boolean', 191 | }, 192 | }, 193 | { 194 | name: 'ProvisionedGsi', 195 | partitionKey: 'provisioned-partition', 196 | writeUnits: 1, 197 | readUnits: 5, 198 | }, 199 | { 200 | name: 'KeysProjectedGsi', 201 | partitionKey: 'projects-keys-partition', 202 | projection: 'keys', 203 | }, 204 | { 205 | name: 'IncludesProjectionGsi', 206 | partitionKey: 'projects-includes-partition', 207 | projection: [ 208 | 'field1', 209 | 'field2', 210 | ], 211 | }, 212 | ], 213 | }; 214 | 215 | const result = createProperties(plugin, table); 216 | 217 | expect(result.AttributeDefinitions).toEqual([ 218 | { AttributeName: 'id', AttributeType: 'S' }, 219 | { AttributeName: 'only-partition', AttributeType: 'S' }, 220 | { AttributeName: 'both-partition', AttributeType: 'S' }, 221 | { AttributeName: 'both-sort', AttributeType: 'S' }, 222 | { AttributeName: 'complex-partition', AttributeType: 'N' }, 223 | { AttributeName: 'complex-sort', AttributeType: 'BOOL' }, 224 | { AttributeName: 'provisioned-partition', AttributeType: 'S' }, 225 | { AttributeName: 'projects-keys-partition', AttributeType: 'S' }, 226 | { AttributeName: 'projects-includes-partition', AttributeType: 'S' }, 227 | ]); 228 | expect(result.GlobalSecondaryIndexes).toMatchSnapshot(); 229 | expect(result.LocalSecondaryIndexes).not.toBeDefined(); 230 | }); 231 | 232 | test('Local Secondary Indexes', () => { 233 | const plugin = createPlugin(); 234 | const table = { 235 | name: 'TestTable', 236 | partitionKey: 'id', 237 | indexes: [ 238 | { 239 | name: 'OnlyKeysLsi', 240 | sortKey: 'only-sort', 241 | }, 242 | { 243 | name: 'ComplexKeysLsi', 244 | sortKey: { 245 | name: 'complex-sort', 246 | type: 'number', 247 | }, 248 | }, 249 | { 250 | name: 'ProvisionedLsi', 251 | sortKey: 'provisioned-sort', 252 | writeUnits: 1, 253 | readUnits: 5, 254 | }, 255 | { 256 | name: 'KeysProjectedLsi', 257 | sortKey: 'projects-keys-sort', 258 | projection: 'keys', 259 | }, 260 | { 261 | name: 'IncludesProjectionLsi', 262 | sortKey: 'projects-includes-sort', 263 | projection: [ 264 | 'field1', 265 | 'field2', 266 | ], 267 | }, 268 | ], 269 | }; 270 | 271 | const result = createProperties(plugin, table); 272 | 273 | expect(result.AttributeDefinitions).toEqual([ 274 | { AttributeName: 'id', AttributeType: 'S' }, 275 | { AttributeName: 'only-sort', AttributeType: 'S' }, 276 | { AttributeName: 'complex-sort', AttributeType: 'N' }, 277 | { AttributeName: 'provisioned-sort', AttributeType: 'S' }, 278 | { AttributeName: 'projects-keys-sort', AttributeType: 'S' }, 279 | { AttributeName: 'projects-includes-sort', AttributeType: 'S' }, 280 | ]); 281 | expect(result.LocalSecondaryIndexes).toMatchSnapshot(); 282 | expect(result.GlobalSecondaryIndexes).not.toBeDefined(); 283 | }); 284 | }); 285 | -------------------------------------------------------------------------------- /lib/aws/dynamo/__tests__/createResources.test.js: -------------------------------------------------------------------------------- 1 | const createResource = require('../createResource'); 2 | 3 | const { createTableResource } = require('../testUtils'); 4 | 5 | const createTemplate = () => createTableResource().TestResource; 6 | 7 | beforeEach(() => { 8 | jest.clearAllMocks(); 9 | }); 10 | 11 | describe('createResource', () => { 12 | test('creates default resource name', () => { 13 | const plugin = { 14 | debug: jest.fn(), 15 | }; 16 | const table = { 17 | name: 'testTable', 18 | }; 19 | const template = createTemplate(); 20 | 21 | const result = createResource(plugin, table, template); 22 | 23 | expect(result).toHaveProperty('TestTableDynamoDbTable', template); 24 | }); 25 | 26 | test('creates custom resource name', () => { 27 | const plugin = { 28 | debug: jest.fn(), 29 | }; 30 | const table = { 31 | resourceName: 'CustomResource', 32 | name: 'testTable', 33 | }; 34 | const template = createTemplate(); 35 | 36 | const result = createResource(plugin, table, template); 37 | 38 | expect(result).toHaveProperty('CustomResource', template); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /lib/aws/dynamo/__tests__/createTemplate.test.js: -------------------------------------------------------------------------------- 1 | const createTemplate = require('../createTemplate'); 2 | 3 | const { createTableResource } = require('../testUtils'); 4 | 5 | const createProperties = () => createTableResource().TestResource.Properties; 6 | 7 | beforeEach(() => { 8 | jest.clearAllMocks(); 9 | }); 10 | 11 | describe('createTemplate', () => { 12 | test('creates template', () => { 13 | const plugin = { 14 | debug: jest.fn(), 15 | }; 16 | const table = { 17 | }; 18 | const properties = createProperties(); 19 | 20 | const result = createTemplate(plugin, table, properties); 21 | 22 | expect(result).toBeDefined(); 23 | expect(result).toHaveProperty('Type', 'AWS::DynamoDB::Table'); 24 | expect(result).toHaveProperty('Properties', properties); 25 | }); 26 | 27 | test('merges custom template', () => { 28 | const plugin = { 29 | debug: jest.fn(), 30 | }; 31 | const table = { 32 | template: { 33 | Properties: { 34 | Prop1: 'prop-1', 35 | Prop2: 'prop-2', 36 | }, 37 | DependsOn: 'dependency', 38 | }, 39 | }; 40 | const properties = { 41 | TableName: 'TestTable', 42 | Prop1: 'old-prop', 43 | }; 44 | 45 | const result = createTemplate(plugin, table, properties); 46 | 47 | expect(result).toBeDefined(); 48 | expect(result).toHaveProperty('Type', 'AWS::DynamoDB::Table'); 49 | expect(result).toHaveProperty('Properties', { 50 | TableName: 'TestTable', 51 | Prop1: 'prop-1', 52 | Prop2: 'prop-2', 53 | }); 54 | expect(result).toHaveProperty('DependsOn', 'dependency'); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /lib/aws/dynamo/__tests__/handleDeploymentLimit.test.js: -------------------------------------------------------------------------------- 1 | 2 | const handleDeploymentLimit = require('../handleDeploymentLimit'); 3 | 4 | const { createTableResource } = require('../testUtils'); 5 | 6 | beforeEach(() => { 7 | jest.clearAllMocks(); 8 | }); 9 | 10 | describe('handleDeploymentLimit', () => { 11 | describe('checkDependency', () => { 12 | test('finds dependency', () => { 13 | const dependsOn = ['dep1', 'dep2', 'dep3', 'dep4']; 14 | const remainingTableResources = new Set(['depA', 'depB', 'dep3', 'depD']); 15 | 16 | const result = handleDeploymentLimit.checkDependency(dependsOn, remainingTableResources); 17 | 18 | expect(result).toEqual(true); 19 | }); 20 | 21 | test('no matching dependencies', () => { 22 | const dependsOn = ['dep1', 'dep2', 'dep3', 'dep4']; 23 | const remainingTableResources = new Set(['depA', 'depB', 'depC', 'depD']); 24 | 25 | const result = handleDeploymentLimit.checkDependency(dependsOn, remainingTableResources); 26 | 27 | expect(result).toEqual(false); 28 | }); 29 | }); 30 | 31 | describe('createBatch', () => { 32 | test('creates a batch with internal dependencies', () => { 33 | const remainingTables = [ 34 | createTableResource({ resourceName: 'res1', dependsOn: 'res9' }), 35 | createTableResource({ resourceName: 'res2', dependsOn: 'res1000' }), 36 | createTableResource({ resourceName: 'res3' }), 37 | createTableResource({ resourceName: 'res4' }), 38 | createTableResource({ resourceName: 'res5' }), 39 | createTableResource({ resourceName: 'res6' }), 40 | createTableResource({ resourceName: 'res7' }), 41 | createTableResource({ resourceName: 'res8' }), 42 | createTableResource({ resourceName: 'res9' }), 43 | createTableResource({ resourceName: 'res10', dependsOn: 'res9' }), 44 | createTableResource({ resourceName: 'res11', dependsOn: 'res10' }), 45 | createTableResource({ resourceName: 'res12', dependsOn: 'res11' }), 46 | ]; 47 | const batchSize = 5; 48 | 49 | const result = handleDeploymentLimit.createBatch(remainingTables, batchSize); 50 | 51 | expect(result).toMatchSnapshot(); 52 | expect(remainingTables).toMatchSnapshot(); 53 | }); 54 | 55 | test('creates a batch assigns depends on', () => { 56 | const remainingTables = [ 57 | createTableResource({ resourceName: 'res1', dependsOn: 'res4' }), 58 | createTableResource({ resourceName: 'res2', dependsOn: 'res5' }), 59 | createTableResource({ resourceName: 'res3' }), 60 | createTableResource({ resourceName: 'res4' }), 61 | createTableResource({ resourceName: 'res5', dependsOn: 'res999' }), 62 | ]; 63 | const batchSize = 5; 64 | const dependsOn = 'otherRes'; 65 | 66 | const result = handleDeploymentLimit.createBatch(remainingTables, batchSize, dependsOn); 67 | 68 | expect(Object.values(result[0])[0].DependsOn[0]).toEqual(dependsOn); 69 | expect(result).toMatchSnapshot(); 70 | expect(remainingTables).toMatchSnapshot(); 71 | }); 72 | }); 73 | 74 | test('batches tables', () => { 75 | const plugin = { 76 | debug: jest.fn(), 77 | getOptions: jest.fn(() => ({})), 78 | }; 79 | const tables = []; // ignored 80 | const incomingTables = [ 81 | createTableResource({ resourceName: 'res1', dependsOn: 'res9' }), 82 | createTableResource({ resourceName: 'res2', dependsOn: 'res1' }), 83 | createTableResource({ resourceName: 'res3' }), 84 | createTableResource({ resourceName: 'res4' }), 85 | createTableResource({ resourceName: 'res5' }), 86 | createTableResource({ resourceName: 'res6' }), 87 | createTableResource({ resourceName: 'res7' }), 88 | createTableResource({ resourceName: 'res8' }), 89 | createTableResource({ resourceName: 'res9' }), 90 | createTableResource({ resourceName: 'res10', dependsOn: 'res9' }), 91 | createTableResource({ resourceName: 'res11', dependsOn: 'res10' }), 92 | createTableResource({ resourceName: 'res12', dependsOn: 'res11' }), 93 | ]; 94 | 95 | const result = handleDeploymentLimit(plugin, tables, incomingTables); 96 | 97 | expect(result).toMatchSnapshot(); 98 | }); 99 | 100 | test('circular dependency', () => { 101 | const plugin = { 102 | debug: jest.fn(), 103 | getOptions: jest.fn(() => ({})), 104 | }; 105 | const tables = []; // ignored 106 | const incomingTables = [ 107 | createTableResource({ resourceName: 'res1', dependsOn: 'res3' }), 108 | createTableResource({ resourceName: 'res2', dependsOn: 'res1' }), 109 | createTableResource({ resourceName: 'res3', dependsOn: 'res2' }), 110 | ]; 111 | 112 | expect(() => handleDeploymentLimit(plugin, tables, incomingTables)).toThrow(); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /lib/aws/dynamo/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | const Plugin = require('../../..'); 2 | 3 | const dynamo = require('..'); 4 | 5 | beforeEach(() => { 6 | jest.clearAllMocks(); 7 | }); 8 | 9 | const dynamoService = { 10 | provider: { 11 | name: 'aws', 12 | }, 13 | 14 | custom: { 15 | tables: { 16 | dynamo: { 17 | deploymentBatchSize: 2, 18 | }, 19 | }, 20 | }, 21 | 22 | resources: { 23 | tables: { 24 | Music: { 25 | partitionKey: 'Artist', 26 | sortKey: 'SongTitle', 27 | indexes: [ 28 | { 29 | name: 'GenreByTitleIndex', 30 | partitionKey: 'Genre', 31 | sortKey: 'AlbumTitle', 32 | }, 33 | ], 34 | }, 35 | People: { 36 | resourceName: 'FavoritePeopleDynamoTable', 37 | partitionKey: 'personID', 38 | sortKey: 'state', 39 | readUnits: 5, 40 | writeUnits: 5, 41 | indexes: [ 42 | { 43 | name: 'EmailIndex', 44 | partitionKey: 'email', 45 | readUnits: 2, 46 | writeUnits: 2, 47 | projection: 'all', 48 | }, 49 | { 50 | name: 'PersonByCreatedTimeIndex', 51 | sortKey: { 52 | name: 'createdTime', 53 | type: 'number', 54 | }, 55 | readUnits: 2, 56 | writeUnits: 2, 57 | projection: 'keys', 58 | }, 59 | { 60 | name: 'PersonByAgeIndex', 61 | sortKey: { 62 | name: 'age', 63 | type: 'number', 64 | }, 65 | readUnits: 2, 66 | writeUnits: 2, 67 | projection: [ 68 | 'dob', 69 | 'firstName', 70 | 'lastName', 71 | ], 72 | }, 73 | ], 74 | streamType: 'newItem', 75 | ttlKey: 'expirationTime', 76 | encrypted: true, 77 | pointInTimeRecovery: true, 78 | tags: { 79 | STAGE: 'test', 80 | TEAM: 'backend', 81 | }, 82 | template: { 83 | Properties: { 84 | ProvisionedThroughput: { 85 | ReadCapacityUnits: 1, 86 | }, 87 | }, 88 | }, 89 | }, 90 | }, 91 | Resources: { 92 | ExistingDynamoDBTable: { 93 | Type: 'AWS::DynamoDB::Table', 94 | DependsOn: 'FavoritePeopleDynamoTable', 95 | Properties: { 96 | TableName: 'ExistingTable', 97 | BillingMode: 'PAY_PER_REQUEST', 98 | AttributeDefinitions: [ 99 | { 100 | AttributeName: 'userID', 101 | AttributeType: 'S', 102 | }, 103 | { 104 | AttributeName: 'email', 105 | AttributeType: 'S', 106 | }, 107 | { 108 | AttributeName: 'createdTime', 109 | AttributeType: 'N', 110 | }, 111 | ], 112 | KeySchema: [ 113 | { 114 | AttributeName: 'userID', 115 | KeyType: 'HASH', 116 | }, 117 | { 118 | AttributeName: 'email', 119 | KeyType: 'RANGE', 120 | }, 121 | ], 122 | LocalSecondaryIndexes: [ 123 | { 124 | IndexName: 'UsersTableEmailGSI', 125 | KeySchema: [ 126 | { 127 | AttributeName: 'userID', 128 | KeyType: 'HASH', 129 | }, 130 | { 131 | AttributeName: 'createdTime', 132 | KeyType: 'RANGE', 133 | }, 134 | ], 135 | Projection: { 136 | ProjectionType: 'ALL', 137 | }, 138 | }, 139 | ], 140 | }, 141 | }, 142 | }, 143 | }, 144 | }; 145 | 146 | describe('dynamo', () => { 147 | test('default export', () => { 148 | expect(dynamo).toBeDefined(); 149 | expect(dynamo).toMatchSnapshot(); 150 | }); 151 | 152 | test('end to end', async () => { 153 | const mockServerless = { 154 | getProvider: jest.fn(), 155 | service: dynamoService, 156 | }; 157 | const mockOptions = {}; 158 | 159 | const instance = new Plugin(mockServerless, mockOptions); 160 | 161 | await instance.process(); 162 | 163 | expect(mockServerless.service.resources).toMatchSnapshot(); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /lib/aws/dynamo/__tests__/mergeExistingTables.test.js: -------------------------------------------------------------------------------- 1 | const mergeExistingTables = require('../mergeExistingTables'); 2 | 3 | const { createTableResource } = require('../testUtils'); 4 | 5 | beforeEach(() => { 6 | jest.clearAllMocks(); 7 | }); 8 | 9 | const createPlugin = (existing) => ({ 10 | debug: jest.fn(), 11 | service: { 12 | resources: !existing ? {} : { 13 | Resources: existing.reduce((res, e) => { 14 | Object.assign(res, e); 15 | return res; 16 | }, {}), 17 | }, 18 | }, 19 | 20 | }); 21 | 22 | describe('mergeExistingTables', () => { 23 | test('merges existing tables', () => { 24 | const existing = [ 25 | createTableResource({ resourceName: 'Existing1', tableName: 'Existing1' }), 26 | createTableResource({ resourceName: 'Existing2', tableName: 'Existing2' }), 27 | createTableResource({ resourceName: 'Existing3', tableName: 'Existing3' }), 28 | ]; 29 | const plugin = createPlugin(existing); 30 | const tables = {}; // ignored 31 | const processedTables = [ 32 | createTableResource({ resourceName: 'New1', tableName: 'New1' }), 33 | createTableResource({ resourceName: 'New2', tableName: 'New2' }), 34 | createTableResource({ resourceName: 'New3', tableName: 'New3' }), 35 | ]; 36 | 37 | const result = mergeExistingTables(plugin, tables, processedTables); 38 | 39 | expect(result).toHaveLength(6); 40 | expect(result).toContainEqual(existing[0]); 41 | expect(result).toContainEqual(processedTables[0]); 42 | }); 43 | 44 | test('no existing tables', () => { 45 | const plugin = createPlugin(); 46 | const tables = {}; // ignored 47 | const processedTables = [ 48 | createTableResource({ resourceName: 'New1', tableName: 'New1' }), 49 | createTableResource({ resourceName: 'New2', tableName: 'New2' }), 50 | createTableResource({ resourceName: 'New3', tableName: 'New3' }), 51 | ]; 52 | 53 | const result = mergeExistingTables(plugin, tables, processedTables); 54 | 55 | expect(result).toHaveLength(3); 56 | expect(result).toContainEqual(processedTables[0]); 57 | expect(result).toContainEqual(processedTables[2]); 58 | }); 59 | 60 | test('table resource name already exists', () => { 61 | const existing = [ 62 | createTableResource({ resourceName: 'Existing1', tableName: 'Existing1' }), 63 | ]; 64 | const plugin = createPlugin(existing); 65 | const tables = {}; // ignored 66 | const processedTables = [ 67 | createTableResource({ resourceName: 'Existing1', tableName: 'New1' }), 68 | ]; 69 | 70 | expect(() => mergeExistingTables(plugin, tables, processedTables)).toThrow(); 71 | }); 72 | 73 | test('table name already exists', () => { 74 | const existing = [ 75 | createTableResource({ resourceName: 'Existing1', tableName: 'Existing1' }), 76 | ]; 77 | const plugin = createPlugin(existing); 78 | const tables = {}; // ignored 79 | const processedTables = [ 80 | createTableResource({ resourceName: 'New1', tableName: 'Existing1' }), 81 | ]; 82 | 83 | expect(() => mergeExistingTables(plugin, tables, processedTables)).toThrow(); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /lib/aws/dynamo/addToResources.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = (plugin, tables, processedTables) => { 3 | if (!plugin.service.resources.Resources) { 4 | // eslint-disable-next-line no-param-reassign 5 | plugin.service.resources.Resources = {}; 6 | } 7 | 8 | processedTables.forEach((table) => ( 9 | Object.assign(plugin.service.resources.Resources, table))); 10 | return processedTables; 11 | }; 12 | -------------------------------------------------------------------------------- /lib/aws/dynamo/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | CLOUD_FORMATION_TYPE: 'AWS::DynamoDB::Table', 3 | }; 4 | -------------------------------------------------------------------------------- /lib/aws/dynamo/createProperties.js: -------------------------------------------------------------------------------- 1 | 2 | const mapper = require('object-mapper'); 3 | const merge = require('merge-deep'); 4 | const { dedupe } = require('../../utils'); 5 | 6 | const DEFAULT_DATA_TYPE = 'string'; 7 | const DATA_TYPE_MAP = { 8 | string: 'S', 9 | number: 'N', 10 | binary: 'B', 11 | boolean: 'BOOL', 12 | list: 'L', 13 | map: 'M', 14 | numberSet: 'NS', 15 | stringSet: 'SS', 16 | binarySet: 'BS', 17 | null: 'NULL', 18 | }; 19 | 20 | const STREAM_TYPE_MAP = { 21 | newItem: 'NEW_IMAGE', 22 | oldItem: 'OLD_IMAGE', 23 | both: 'NEW_AND_OLD_IMAGES', 24 | keys: 'KEYS_ONLY', 25 | }; 26 | 27 | const DEFAULT_PROJECTION = 'all'; 28 | const PROJECTION_TYPE_MAP = { 29 | all: 'ALL', 30 | keys: 'KEYS_ONLY', 31 | include: 'INCLUDE', 32 | }; 33 | 34 | const DEFAULT_PROPERTIES = { 35 | BillingMode: 'PAY_PER_REQUEST', 36 | }; 37 | 38 | const KEY_TYPES = { 39 | partitionKey: 'HASH', 40 | sortKey: 'RANGE', 41 | }; 42 | 43 | const keyMapping = { 44 | name: 'AttributeName', 45 | keyType: 'KeyType', 46 | }; 47 | 48 | // eslint-disable-next-line no-unused-vars 49 | const createKeyTransform = (attrDst) => (srcValue, srcObject, dstObject, srcKey, dstKey) => { 50 | if (!(srcKey in srcObject)) { 51 | return undefined; 52 | } 53 | 54 | let keyDef; 55 | if (typeof srcValue === 'string') { 56 | keyDef = { 57 | name: srcValue, 58 | type: DEFAULT_DATA_TYPE, 59 | }; 60 | } else { 61 | keyDef = srcValue; 62 | } 63 | 64 | const { name, type: dataType } = keyDef; 65 | 66 | const mapped = { 67 | name, 68 | keyType: KEY_TYPES[srcKey], 69 | }; 70 | 71 | const attributeDefinition = { 72 | AttributeName: name, 73 | AttributeType: DATA_TYPE_MAP[dataType], 74 | }; 75 | const attributeDestination = attrDst || dstObject; 76 | mapper.setKeyValue(attributeDestination, 'AttributeDefinitions[]+', attributeDefinition); 77 | 78 | return mapper(mapped, keyMapping); 79 | }; 80 | 81 | // eslint-disable-next-line no-unused-vars 82 | const capacityUnitsTransform = (srcValue, srcObject, dstObject, srcKey, dstKey) => { 83 | if (!(srcKey in srcObject)) { 84 | return undefined; 85 | } 86 | 87 | mapper.setKeyValue(dstObject, 'BillingMode', 'PROVISIONED'); 88 | return srcValue; 89 | }; 90 | 91 | const createSecondaryIndexMapping = (attrDst) => ({ 92 | name: 'IndexName', 93 | partitionKey: { 94 | key: 'KeySchema[]+', 95 | transform: createKeyTransform(attrDst), 96 | }, 97 | sortKey: { 98 | key: 'KeySchema[]+', 99 | transform: createKeyTransform(attrDst), 100 | }, 101 | projection: { 102 | key: 'Projection', 103 | default: DEFAULT_PROJECTION, 104 | // eslint-disable-next-line no-unused-vars 105 | transform: (srcValue, srcObject, dstObject, srcKey, dstKey) => { 106 | const mapped = {}; 107 | let projectionType; 108 | if (Array.isArray(srcValue)) { 109 | mapped.NonKeyAttributes = srcValue; 110 | projectionType = 'include'; 111 | } else { 112 | projectionType = srcValue; 113 | } 114 | 115 | mapped.ProjectionType = PROJECTION_TYPE_MAP[projectionType]; 116 | return mapped; 117 | }, 118 | }, 119 | readUnits: { 120 | key: 'ProvisionedThroughput.ReadCapacityUnits', 121 | // eslint-disable-next-line no-unused-vars 122 | transform: (srcValue, srcObject, dstObject, srcKey, dstKey) => { 123 | if (!(srcKey in srcObject)) { 124 | return undefined; 125 | } 126 | return srcValue; 127 | }, 128 | }, 129 | writeUnits: { 130 | key: 'ProvisionedThroughput.WriteCapacityUnits', 131 | // eslint-disable-next-line no-unused-vars 132 | transform: (srcValue, srcObject, dstObject, srcKey, dstKey) => { 133 | if (!(srcKey in srcObject)) { 134 | return undefined; 135 | } 136 | return srcValue; 137 | }, 138 | }, 139 | }); 140 | 141 | const propertiesMapping = { 142 | name: 'TableName', 143 | partitionKey: { 144 | key: 'KeySchema[]+', 145 | transform: createKeyTransform(), 146 | }, 147 | sortKey: { 148 | key: 'KeySchema[]+', 149 | transform: createKeyTransform(), 150 | }, 151 | readUnits: { 152 | key: 'ProvisionedThroughput.ReadCapacityUnits', 153 | transform: capacityUnitsTransform, 154 | }, 155 | writeUnits: { 156 | key: 'ProvisionedThroughput.WriteCapacityUnits', 157 | transform: capacityUnitsTransform, 158 | }, 159 | indexes: { 160 | // eslint-disable-next-line no-unused-vars 161 | transform: (srcValue, srcObject, dstObject, srcKey, dstKey) => { 162 | if (!Array.isArray(srcValue)) { 163 | return; 164 | } 165 | 166 | srcValue.forEach((index) => { 167 | const secondaryIndexMapping = createSecondaryIndexMapping(dstObject); 168 | if ('partitionKey' in index) { 169 | // GlobalSecondaryIndex 170 | const mappedIndex = mapper(index, secondaryIndexMapping); 171 | mapper.setKeyValue(dstObject, 'GlobalSecondaryIndexes[]+', mappedIndex); 172 | } else { 173 | // LocalSecondaryIndex 174 | const localIndex = { 175 | ...index, 176 | partitionKey: srcObject.partitionKey, 177 | }; 178 | const mappedIndex = mapper(localIndex, secondaryIndexMapping); 179 | mapper.setKeyValue(dstObject, 'LocalSecondaryIndexes[]+', mappedIndex); 180 | } 181 | }); 182 | }, 183 | }, 184 | tags: { 185 | key: 'Tags', 186 | // eslint-disable-next-line no-unused-vars 187 | transform: (srcValue, srcObject, dstObject, srcKey, dstKey) => { 188 | if (!(srcKey in srcObject)) { 189 | return undefined; 190 | } 191 | 192 | return Object.entries(srcValue).map(([key, value]) => ({ 193 | Key: key, 194 | Value: value, 195 | })); 196 | }, 197 | }, 198 | streamType: { 199 | key: 'StreamSpecification.StreamViewType', 200 | // eslint-disable-next-line no-unused-vars 201 | transform: (srcValue, srcObject, dstObject, srcKey, dstKey) => { 202 | if (!(srcKey in srcObject)) { 203 | return undefined; 204 | } 205 | return STREAM_TYPE_MAP[srcValue]; 206 | }, 207 | }, 208 | ttlKey: { 209 | key: 'TimeToLiveSpecification', 210 | // eslint-disable-next-line no-unused-vars 211 | transform: (srcValue, srcObject, dstObject, srcKey, dstKey) => { 212 | if (!(srcKey in srcObject)) { 213 | return undefined; 214 | } 215 | return { 216 | AttributeName: srcValue, 217 | Enabled: true, 218 | }; 219 | }, 220 | }, 221 | encrypted: { 222 | key: 'SSESpecification.SSEEnabled', 223 | // eslint-disable-next-line no-unused-vars 224 | transform: (srcValue, srcObject, dstObject, srcKey, dstKey) => { 225 | if (!(srcKey in srcObject)) { 226 | return undefined; 227 | } 228 | return srcValue; 229 | }, 230 | }, 231 | pointInTimeRecovery: { 232 | key: 'PointInTimeRecoverySpecification.PointInTimeRecoveryEnabled', 233 | // eslint-disable-next-line no-unused-vars 234 | transform: (srcValue, srcObject, dstObject, srcKey, dstKey) => { 235 | if (!(srcKey in srcObject)) { 236 | return undefined; 237 | } 238 | return srcValue; 239 | }, 240 | }, 241 | }; 242 | 243 | module.exports = (plugin, table) => { 244 | const mappedTable = mapper(table, propertiesMapping); 245 | const mergedTable = merge(DEFAULT_PROPERTIES, mappedTable); 246 | 247 | // Deduplicate Attribute Definitions 248 | mergedTable.AttributeDefinitions = dedupe( 249 | mergedTable.AttributeDefinitions, 250 | (({ AttributeName }) => AttributeName), 251 | ); 252 | 253 | plugin.debug('Created properties', JSON.stringify(mergedTable)); 254 | 255 | return mergedTable; 256 | }; 257 | -------------------------------------------------------------------------------- /lib/aws/dynamo/createResource.js: -------------------------------------------------------------------------------- 1 | const pascalCase = require('pascal-case'); 2 | 3 | const RESOURCE_NAME_TEMPLATE = '{tableName}DynamoDbTable'; 4 | 5 | const createResourceName = (table) => RESOURCE_NAME_TEMPLATE.replace('{tableName}', pascalCase(table.name)); 6 | 7 | module.exports = (plugin, table, template) => { 8 | const resourceName = table.resourceName || createResourceName(table); 9 | const resource = { 10 | [resourceName]: template, 11 | }; 12 | 13 | plugin.debug('Created resource', JSON.stringify(resource)); 14 | 15 | return resource; 16 | }; 17 | -------------------------------------------------------------------------------- /lib/aws/dynamo/createTemplate.js: -------------------------------------------------------------------------------- 1 | const merge = require('merge-deep'); 2 | 3 | const { CLOUD_FORMATION_TYPE } = require('./constants'); 4 | 5 | module.exports = (plugin, table, properties) => { 6 | const computedTemplate = { 7 | Type: CLOUD_FORMATION_TYPE, 8 | Properties: properties, 9 | }; 10 | const customTemplate = table.template; 11 | const merged = merge(computedTemplate, customTemplate); 12 | 13 | plugin.debug('Created template', JSON.stringify(merged)); 14 | 15 | return merged; 16 | }; 17 | -------------------------------------------------------------------------------- /lib/aws/dynamo/handleDeploymentLimit.js: -------------------------------------------------------------------------------- 1 | const { dedupe } = require('../../utils'); 2 | 3 | // Dynamo has a limit on the number of tables that can be 4 | // deployed at a time. This step helps get around that. 5 | // https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Limits.html#limits-api 6 | const MAX_TABLE_BATCH = 10; 7 | 8 | const checkDependency = (dependsOn, remainingTableResources) => { 9 | for (let index = 0; index < dependsOn.length; index += 1) { 10 | const dependency = dependsOn[index]; 11 | if (remainingTableResources.has(dependency)) { 12 | return true; 13 | } 14 | } 15 | 16 | return false; 17 | }; 18 | 19 | const createBatch = (remainingTables, batchSize, batchDependency) => { 20 | const remainingTableResources = new Set(remainingTables.map((table) => Object.keys(table)[0])); 21 | const batch = []; 22 | let index = 0; 23 | while (batch.length < batchSize && index < remainingTables.length) { 24 | const table = remainingTables[index]; 25 | const tableTemplate = Object.values(table)[0]; 26 | const dependsOn = tableTemplate.DependsOn || []; 27 | const dependsOnList = Array.isArray(dependsOn) ? dependsOn : [dependsOn]; 28 | if (checkDependency(dependsOnList, remainingTableResources)) { 29 | index += 1; 30 | continue; 31 | } 32 | 33 | if (batchDependency) { 34 | dependsOnList.push(batchDependency); 35 | tableTemplate.DependsOn = dedupe(dependsOnList); 36 | } 37 | 38 | batch.push(table); 39 | remainingTables.splice(index, 1); 40 | const tableResourceName = Object.values(table)[0]; 41 | remainingTableResources.delete(tableResourceName); 42 | } 43 | 44 | return batch; 45 | }; 46 | 47 | module.exports = (plugin, tables, incomingTables) => { 48 | const { dynamo = {} } = plugin.getOptions(); 49 | const batchSize = dynamo.deploymentBatchSize || MAX_TABLE_BATCH; 50 | 51 | const processedTables = []; 52 | while (incomingTables.length > 0) { 53 | const lastProcessedTable = processedTables[processedTables.length - 1]; 54 | const lastResource = lastProcessedTable ? Object.keys(lastProcessedTable)[0] : null; 55 | const batch = createBatch(incomingTables, batchSize, lastResource); 56 | if (batch.length === 0) { 57 | throw new Error('Circular table dependency found. Crosscheck existing resources with \'tables\''); 58 | } 59 | 60 | plugin.debug('Created deployment batch', JSON.stringify(batch)); 61 | 62 | processedTables.push(...batch); 63 | } 64 | 65 | return processedTables; 66 | }; 67 | 68 | Object.assign(module.exports, { 69 | checkDependency, 70 | createBatch, 71 | }); 72 | -------------------------------------------------------------------------------- /lib/aws/dynamo/index.js: -------------------------------------------------------------------------------- 1 | // tableSteps 2 | const createProperties = require('./createProperties'); 3 | const createTemplate = require('./createTemplate'); 4 | const createResource = require('./createResource'); 5 | 6 | // postProcessSteps 7 | const mergeExistingTables = require('./mergeExistingTables'); 8 | const handleDeploymentLimit = require('./handleDeploymentLimit'); 9 | const addToResources = require('./addToResources'); 10 | 11 | module.exports = { 12 | tableSteps: [ 13 | createProperties, 14 | createTemplate, 15 | createResource, 16 | ], 17 | postProcessSteps: [ 18 | mergeExistingTables, 19 | handleDeploymentLimit, 20 | addToResources, 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /lib/aws/dynamo/mergeExistingTables.js: -------------------------------------------------------------------------------- 1 | 2 | const { CLOUD_FORMATION_TYPE } = require('./constants'); 3 | 4 | const findExisting = (resources = {}) => ( 5 | Object.entries(resources) 6 | .filter(([, template]) => template.Type === CLOUD_FORMATION_TYPE) 7 | .map(([name, template]) => ({ 8 | [name]: template, 9 | })) 10 | ); 11 | 12 | const getTableName = (table) => Object.values(table)[0].Properties.TableName; 13 | 14 | const getResourceName = (table) => Object.keys(table)[0]; 15 | 16 | const checkDuplicateValues = (tables, getter, name) => { 17 | const seen = new Set(); 18 | tables.forEach((table) => { 19 | const value = getter(table); 20 | if (seen.has(value)) { 21 | throw new Error(`Duplicate ${name} found: ${value}. Crosscheck existing resources with 'tables'`); 22 | } 23 | seen.add(value); 24 | }); 25 | }; 26 | 27 | const checkDuplicates = (tables) => { 28 | checkDuplicateValues(tables, getResourceName, 'Resource Name'); 29 | checkDuplicateValues(tables, getTableName, 'Table Name'); 30 | }; 31 | 32 | module.exports = (plugin, tables, processedTables) => { 33 | const existing = findExisting(plugin.service.resources.Resources); 34 | 35 | plugin.debug('Found existing tables', JSON.stringify(existing)); 36 | const allTables = existing.concat(processedTables); 37 | 38 | checkDuplicates(allTables); 39 | 40 | return allTables; 41 | }; 42 | -------------------------------------------------------------------------------- /lib/aws/dynamo/testUtils.js: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | if (process.env.NODE_ENV !== 'test') { 3 | throw new Error('Invalid use of test utils'); 4 | } 5 | 6 | const { CLOUD_FORMATION_TYPE } = require('./constants'); 7 | 8 | const createTableResource = ({ 9 | resourceName = 'TestResource', 10 | dependsOn, 11 | tableName = 'TestTable', 12 | } = {}) => ({ 13 | [resourceName]: { 14 | Type: CLOUD_FORMATION_TYPE, 15 | ...(!dependsOn ? {} : { DependsOn: dependsOn }), 16 | Properties: { 17 | TableName: tableName, 18 | }, 19 | }, 20 | }); 21 | 22 | module.exports = { 23 | createTableResource, 24 | }; 25 | -------------------------------------------------------------------------------- /lib/aws/index.js: -------------------------------------------------------------------------------- 1 | const dynamo = require('./dynamo'); 2 | const { PACKAGE_NAME } = require('..'); 3 | 4 | const DEFAULT_TYPE = 'dynamo'; 5 | 6 | const checkPluginCompatibility = (plugin) => { 7 | const { plugins = [] } = plugin.service; 8 | 9 | if (plugins.includes('serverless-dynamodb-local')) { 10 | if (plugins.indexOf('serverless-dynamodb-local') < plugins.indexOf(PACKAGE_NAME)) { 11 | plugin.log(`\n\nWARNING!!! Found serverless-dynamodb-local before ${PACKAGE_NAME}.\n\nTo ensure compatibility, please list ${PACKAGE_NAME} before serverless-dynamodb-local in your plugins list`); 12 | } 13 | Object.assign(plugin.hooks, { 14 | // Hooks https://github.com/99xt/serverless-dynamodb-local 15 | 'before:dynamodb:migrate:migrateHandler': () => plugin.spawn('process'), 16 | 'before:dynamodb:start:startHandler': () => plugin.spawn('process'), 17 | 'before:offline:start:init': () => plugin.spawn('process'), 18 | }); 19 | } 20 | }; 21 | 22 | module.exports = { 23 | DEFAULT_TYPE, 24 | checkPluginCompatibility, 25 | dynamo, 26 | }; 27 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 2 | const { separateByType } = require('./utils'); 3 | 4 | const PLUGIN_NAME = 'tables'; 5 | const PACKAGE_NAME = `serverless-plugin-${PLUGIN_NAME}`; 6 | 7 | class TablesPlugin { 8 | constructor(serverless, options) { 9 | this.serverless = serverless; 10 | this.options = options; 11 | this.service = serverless.service; 12 | this.isVerbose = options.verbose || options.v; 13 | this.isDebug = options.debug || options.d; 14 | 15 | this.commands = { 16 | [PLUGIN_NAME]: { 17 | usage: 'Easily manage table resources', 18 | lifecycleEvents: [ 19 | PLUGIN_NAME, 20 | ], 21 | options: { 22 | verbose: { 23 | usage: 'Verbose output', 24 | shortcut: 'v', 25 | }, 26 | debug: { 27 | usage: 'Debug the plugin', 28 | shortcut: 'd', 29 | }, 30 | }, 31 | commands: { 32 | process: { 33 | usage: 'Process table definitions', 34 | lifecycleEvents: [ 35 | 'process', 36 | ], 37 | }, 38 | }, 39 | }, 40 | }; 41 | 42 | this.hooks = { 43 | [`${PLUGIN_NAME}:process:process`]: () => this.process(), 44 | 'before:package:createDeploymentArtifacts': () => this.spawn('process'), 45 | }; 46 | 47 | this.checkPluginCompatibility(); 48 | } 49 | 50 | log(...args) { 51 | const message = args.join(' '); 52 | this.serverless.cli.consoleLog(`${PACKAGE_NAME}: ${message}`); 53 | } 54 | 55 | verbose(...args) { 56 | if (!this.isVerbose && !this.isDebug) { 57 | return; 58 | } 59 | 60 | this.log(...args); 61 | } 62 | 63 | debug(...args) { 64 | if (!this.isDebug) { 65 | return; 66 | } 67 | 68 | this.log(...args); 69 | } 70 | 71 | spawn(command) { 72 | return this.serverless.pluginManager.spawn(`${PLUGIN_NAME}:${command}`); 73 | } 74 | 75 | getOptions() { 76 | const { custom = {} } = this.service; 77 | const { tables: options = {} } = custom; 78 | return options; 79 | } 80 | 81 | getProviderDefinition() { 82 | const providerName = this.service.provider.name; 83 | const providerPath = `./${providerName}`; 84 | try { 85 | // eslint-disable-next-line no-dynamic-require, global-require, import/no-dynamic-require 86 | return require(providerPath); 87 | } catch (err) { 88 | if (err.message.startsWith(`Cannot find module '${providerPath}'`)) { 89 | throw new Error(`Provider ${providerName} is not supported by ${PACKAGE_NAME} at this time. Please open an issue.`); 90 | } 91 | 92 | /* istanbul ignore next */ 93 | throw err; 94 | } 95 | } 96 | 97 | checkPluginCompatibility() { 98 | const { checkPluginCompatibility: checkCompatibility } = this.getProviderDefinition(); 99 | if (checkCompatibility) { 100 | checkCompatibility(this); 101 | } 102 | } 103 | 104 | runTableSteps(tableSteps = [], table) { 105 | this.debug('Running table steps for', table.name); 106 | return tableSteps.reduce( 107 | (input, step, index) => { 108 | this.debug('Running table step', index); 109 | return step(this, table, input); 110 | }, 111 | null, 112 | ); 113 | } 114 | 115 | runPostProcessSteps(postProcessSteps = [], tables, processedTables) { 116 | return postProcessSteps.reduce( 117 | (input, step, index) => { 118 | this.debug('Running post process step', index); 119 | return step(this, tables, input); 120 | }, 121 | processedTables, 122 | ); 123 | } 124 | 125 | processTablesForType(tableDef, tables) { 126 | const { tableSteps, postProcessSteps } = tableDef; 127 | const processedTables = tables.map((table) => this.runTableSteps(tableSteps, table)); 128 | return this.runPostProcessSteps(postProcessSteps, tables, processedTables); 129 | } 130 | 131 | process() { 132 | try { 133 | this.verbose('Processing tables...'); 134 | if (!this.service.resources) { 135 | this.verbose('No tables to process'); 136 | return; 137 | } 138 | 139 | const { tables } = this.service.resources; 140 | delete this.service.resources.tables; 141 | if (!tables) { 142 | this.verbose('No tables to process'); 143 | return; 144 | } 145 | 146 | const providerName = this.serverless.service.provider.name; 147 | 148 | const provider = this.getProviderDefinition(); 149 | 150 | const tablesByType = separateByType(tables, provider.DEFAULT_TYPE); 151 | 152 | const tableTypes = Object.keys(tablesByType); 153 | const processed = tableTypes.reduce((acc, tableType) => { 154 | this.debug('Processing table type', tableType); 155 | const tableDef = provider[tableType]; 156 | if (!tableDef) { 157 | throw new Error(`Unknown table type ${tableType} for provider ${providerName}. Please open an issue to request support if it wasn't a typo.`); 158 | } 159 | 160 | acc[tableType] = this.processTablesForType(tableDef, tablesByType[tableType]); 161 | return acc; 162 | }, {}); 163 | this.verbose('Done processing all tables'); 164 | this.verbose('Processed tables:\n', JSON.stringify(processed, null, 2)); 165 | } catch (err) { 166 | if (this.isDebug) { 167 | // Use console to log out stack trace 168 | console.error('ERROR', err); // eslint-disable-line no-console 169 | } 170 | throw err; 171 | } 172 | } 173 | } 174 | 175 | module.exports = TablesPlugin; 176 | Object.assign(module.exports, { 177 | PLUGIN_NAME, 178 | PACKAGE_NAME, 179 | }); 180 | -------------------------------------------------------------------------------- /lib/utils/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | const { separateByType, dedupe } = require('../index'); 2 | 3 | beforeEach(() => { 4 | jest.clearAllMocks(); 5 | }); 6 | 7 | describe('utils', () => { 8 | test('separateByType', () => { 9 | const tables = { 10 | DefaultTable1: { 11 | value: 1, 12 | }, 13 | NoSqlTable1: { 14 | type: 'nosql', 15 | value: 2, 16 | }, 17 | SqlTable1: { 18 | type: 'sql', 19 | value: 3, 20 | }, 21 | SqlTable2: { 22 | name: 'MySqlTable', 23 | type: 'sql', 24 | value: 4, 25 | }, 26 | }; 27 | const defaultType = 'nosql'; 28 | 29 | const result = separateByType(tables, defaultType); 30 | 31 | expect(result).toBeDefined(); 32 | expect(result.sql).toHaveLength(2); 33 | expect(result.sql).toContainEqual({ 34 | name: 'SqlTable1', 35 | type: 'sql', 36 | value: 3, 37 | }); 38 | expect(result.sql).toContainEqual({ 39 | name: 'MySqlTable', 40 | type: 'sql', 41 | value: 4, 42 | }); 43 | expect(result.nosql).toHaveLength(2); 44 | expect(result.nosql).toContainEqual({ 45 | name: 'DefaultTable1', 46 | value: 1, 47 | }); 48 | expect(result.nosql).toContainEqual({ 49 | name: 'NoSqlTable1', 50 | type: 'nosql', 51 | value: 2, 52 | }); 53 | }); 54 | 55 | test('dedupe default getter', () => { 56 | const array = [ 57 | 'a', 58 | 'b', 59 | 'c', 60 | 'c', 61 | 'b', 62 | 'a', 63 | ]; 64 | 65 | const result = dedupe(array); 66 | 67 | expect(result).toHaveLength(3); 68 | expect(result).toEqual(expect.arrayContaining(['a', 'b', 'c'])); 69 | }); 70 | 71 | test('dedupe custom getter', () => { 72 | const array = [ 73 | { id: 1 }, 74 | { id: 2 }, 75 | { id: 3 }, 76 | { id: 3 }, 77 | { id: 2 }, 78 | { id: 1 }, 79 | ]; 80 | const propGetter = ({ id }) => id; 81 | 82 | const result = dedupe(array, propGetter); 83 | 84 | expect(result).toHaveLength(3); 85 | expect(result).toContainEqual({ id: 1 }); 86 | }); 87 | 88 | test('dedupe no input', () => { 89 | const array = null; 90 | 91 | const result = dedupe(array); 92 | 93 | expect(result).toBe(array); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /lib/utils/index.js: -------------------------------------------------------------------------------- 1 | 2 | const separateByType = (tables, defaultType) => ( 3 | Object.entries(tables).reduce((acc, [key, value]) => { 4 | const type = value.type || defaultType; 5 | let typeArray = acc[type]; 6 | if (!Array.isArray(typeArray)) { 7 | typeArray = []; 8 | acc[type] = typeArray; 9 | } 10 | 11 | const tableDef = { name: key, ...value }; 12 | typeArray.push(tableDef); 13 | return acc; 14 | }, {}) 15 | ); 16 | 17 | const dedupe = (array, propGetter) => { 18 | if (!array) { 19 | return array; 20 | } 21 | 22 | const getter = propGetter || ((item) => item); 23 | const seen = new Set(); 24 | return array.reduce((acc, item) => { 25 | const value = getter(item); 26 | if (seen.has(value)) { 27 | return acc; 28 | } 29 | 30 | seen.add(value); 31 | acc.push(item); 32 | return acc; 33 | }, []); 34 | }; 35 | 36 | module.exports = { 37 | separateByType, 38 | dedupe, 39 | }; 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-plugin-tables", 3 | "version": "1.0.2", 4 | "license": "MIT", 5 | "description": "A Serverless plugin that makes adding tables to your service file easy", 6 | "repository": "github:chris-feist/serverless-plugin-tables", 7 | "main": "./lib/index.js", 8 | "scripts": { 9 | "test": "jest", 10 | "integration": "jest --config jest.integration.config.js", 11 | "lint": "yarn run lint:eslint", 12 | "lint:eslint": "eslint . --ext .js" 13 | }, 14 | "keywords": [ 15 | "serverless", 16 | "serverless-plugin", 17 | "aws", 18 | "dynamodb" 19 | ], 20 | "files": [ 21 | "/lib" 22 | ], 23 | "devDependencies": { 24 | "eslint": "^5.14.1", 25 | "eslint-config-airbnb-base": "^13.1.0", 26 | "eslint-plugin-import": "^2.16.0", 27 | "eslint-plugin-jest": "^22.3.0", 28 | "jest": "^24.1.0", 29 | "serverless": "^1.37.1" 30 | }, 31 | "dependencies": { 32 | "merge-deep": "^3.0.2", 33 | "object-mapper": "^5.0.0", 34 | "pascal-case": "^2.0.1" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /serverless-plugin-tables.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "javascript.validate.enable": false, 9 | "eslint.autoFixOnSave": true, 10 | "cSpell.words": [ 11 | "dedupe", 12 | "deduplicated", 13 | "serverless" 14 | ] 15 | } 16 | } --------------------------------------------------------------------------------