├── .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 | }
--------------------------------------------------------------------------------