├── .eslintrc.json ├── .gitattributes ├── .github ├── pull_request_template.md └── workflows │ ├── build.yml │ ├── pull-request-lint.yml │ ├── release.yml │ └── upgrade-main.yml ├── .gitignore ├── .mergify.yml ├── .npmignore ├── .projen ├── deps.json ├── files.json └── tasks.json ├── .projenrc.js ├── LICENSE ├── README.md ├── cdk.json ├── package.json ├── src ├── app.ts ├── custom-resource-is-migration-complete.ts ├── custom-resource-migrations-runner.ts ├── examples │ ├── migrations │ │ └── 20220801-add-attribute.ts │ └── test-stack.ts ├── index.ts ├── migration.ts └── migrations-manager.ts ├── tsconfig.dev.json ├── tsconfig.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true, 4 | "node": true 5 | }, 6 | "root": true, 7 | "plugins": [ 8 | "@typescript-eslint", 9 | "import", 10 | "prettier" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaVersion": 2018, 15 | "sourceType": "module", 16 | "project": "./tsconfig.dev.json" 17 | }, 18 | "extends": [ 19 | "plugin:import/typescript", 20 | "prettier", 21 | "plugin:prettier/recommended" 22 | ], 23 | "settings": { 24 | "import/parsers": { 25 | "@typescript-eslint/parser": [ 26 | ".ts", 27 | ".tsx" 28 | ] 29 | }, 30 | "import/resolver": { 31 | "node": {}, 32 | "typescript": { 33 | "project": "./tsconfig.dev.json", 34 | "alwaysTryTypes": true 35 | } 36 | } 37 | }, 38 | "ignorePatterns": [ 39 | "*.js", 40 | "!.projenrc.js", 41 | "*.d.ts", 42 | "node_modules/", 43 | "*.generated.ts", 44 | "coverage" 45 | ], 46 | "rules": { 47 | "prettier/prettier": [ 48 | "error" 49 | ], 50 | "@typescript-eslint/no-require-imports": [ 51 | "error" 52 | ], 53 | "import/no-extraneous-dependencies": [ 54 | "error", 55 | { 56 | "devDependencies": [ 57 | "**/test/**", 58 | "**/build-tools/**" 59 | ], 60 | "optionalDependencies": false, 61 | "peerDependencies": true 62 | } 63 | ], 64 | "import/no-unresolved": [ 65 | "error" 66 | ], 67 | "import/order": [ 68 | "warn", 69 | { 70 | "groups": [ 71 | "builtin", 72 | "external" 73 | ], 74 | "alphabetize": { 75 | "order": "asc", 76 | "caseInsensitive": true 77 | } 78 | } 79 | ], 80 | "no-duplicate-imports": [ 81 | "error" 82 | ], 83 | "no-shadow": [ 84 | "off" 85 | ], 86 | "@typescript-eslint/no-shadow": [ 87 | "error" 88 | ], 89 | "key-spacing": [ 90 | "error" 91 | ], 92 | "no-multiple-empty-lines": [ 93 | "error" 94 | ], 95 | "@typescript-eslint/no-floating-promises": [ 96 | "error" 97 | ], 98 | "no-return-await": [ 99 | "off" 100 | ], 101 | "@typescript-eslint/return-await": [ 102 | "error" 103 | ], 104 | "no-trailing-spaces": [ 105 | "error" 106 | ], 107 | "dot-notation": [ 108 | "error" 109 | ], 110 | "no-bitwise": [ 111 | "error" 112 | ], 113 | "@typescript-eslint/member-ordering": [ 114 | "error", 115 | { 116 | "default": [ 117 | "public-static-field", 118 | "public-static-method", 119 | "protected-static-field", 120 | "protected-static-method", 121 | "private-static-field", 122 | "private-static-method", 123 | "field", 124 | "constructor", 125 | "method" 126 | ] 127 | } 128 | ] 129 | }, 130 | "overrides": [ 131 | { 132 | "files": [ 133 | ".projenrc.js" 134 | ], 135 | "rules": { 136 | "@typescript-eslint/no-require-imports": "off", 137 | "import/no-extraneous-dependencies": "off" 138 | } 139 | } 140 | ] 141 | } 142 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | *.snap linguist-generated 4 | /.eslintrc.json linguist-generated 5 | /.gitattributes linguist-generated 6 | /.github/pull_request_template.md linguist-generated 7 | /.github/workflows/build.yml linguist-generated 8 | /.github/workflows/pull-request-lint.yml linguist-generated 9 | /.github/workflows/release.yml linguist-generated 10 | /.github/workflows/upgrade-main.yml linguist-generated 11 | /.gitignore linguist-generated 12 | /.mergify.yml linguist-generated 13 | /.npmignore linguist-generated 14 | /.projen/** linguist-generated 15 | /.projen/deps.json linguist-generated 16 | /.projen/files.json linguist-generated 17 | /.projen/tasks.json linguist-generated 18 | /cdk.json linguist-generated 19 | /LICENSE linguist-generated 20 | /package.json linguist-generated 21 | /tsconfig.dev.json linguist-generated 22 | /tsconfig.json linguist-generated 23 | /yarn.lock linguist-generated -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Fixes # -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: build 4 | on: 5 | pull_request: {} 6 | workflow_dispatch: {} 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | outputs: 13 | self_mutation_happened: ${{ steps.self_mutation.outputs.self_mutation_happened }} 14 | env: 15 | CI: "true" 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | with: 20 | ref: ${{ github.event.pull_request.head.ref }} 21 | repository: ${{ github.event.pull_request.head.repo.full_name }} 22 | - name: Install dependencies 23 | run: yarn install --check-files 24 | - name: build 25 | run: npx projen build 26 | - id: self_mutation 27 | name: Find mutations 28 | run: |- 29 | git add . 30 | git diff --staged --patch --exit-code > .repo.patch || echo "::set-output name=self_mutation_happened::true" 31 | - if: steps.self_mutation.outputs.self_mutation_happened 32 | name: Upload patch 33 | uses: actions/upload-artifact@v2 34 | with: 35 | name: .repo.patch 36 | path: .repo.patch 37 | - name: Fail build on mutation 38 | if: steps.self_mutation.outputs.self_mutation_happened 39 | run: |- 40 | echo "::error::Files were changed during build (see build log). If this was triggered from a fork, you will need to update your branch." 41 | cat .repo.patch 42 | exit 1 43 | self-mutation: 44 | needs: build 45 | runs-on: ubuntu-latest 46 | permissions: 47 | contents: write 48 | if: always() && needs.build.outputs.self_mutation_happened && !(github.event.pull_request.head.repo.full_name != github.repository) 49 | steps: 50 | - name: Checkout 51 | uses: actions/checkout@v3 52 | with: 53 | token: ${{ secrets.PROJEN_GITHUB_TOKEN }} 54 | ref: ${{ github.event.pull_request.head.ref }} 55 | repository: ${{ github.event.pull_request.head.repo.full_name }} 56 | - name: Download patch 57 | uses: actions/download-artifact@v3 58 | with: 59 | name: .repo.patch 60 | path: ${{ runner.temp }} 61 | - name: Apply patch 62 | run: '[ -s ${{ runner.temp }}/.repo.patch ] && git apply ${{ runner.temp }}/.repo.patch || echo "Empty patch. Skipping."' 63 | - name: Set git identity 64 | run: |- 65 | git config user.name "github-actions" 66 | git config user.email "github-actions@github.com" 67 | - name: Push changes 68 | run: |2- 69 | git add . 70 | git commit -s -m "chore: self mutation" 71 | git push origin HEAD:${{ github.event.pull_request.head.ref }} 72 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-lint.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: pull-request-lint 4 | on: 5 | pull_request_target: 6 | types: 7 | - labeled 8 | - opened 9 | - synchronize 10 | - reopened 11 | - ready_for_review 12 | - edited 13 | jobs: 14 | validate: 15 | name: Validate PR title 16 | runs-on: ubuntu-latest 17 | permissions: 18 | pull-requests: write 19 | steps: 20 | - uses: amannn/action-semantic-pull-request@v4.5.0 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | with: 24 | types: |- 25 | feat 26 | fix 27 | chore 28 | requireScope: false 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: release 4 | on: 5 | push: 6 | branches: 7 | - main 8 | workflow_dispatch: {} 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | outputs: 15 | latest_commit: ${{ steps.git_remote.outputs.latest_commit }} 16 | env: 17 | CI: "true" 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v3 21 | with: 22 | fetch-depth: 0 23 | - name: Set git identity 24 | run: |- 25 | git config user.name "github-actions" 26 | git config user.email "github-actions@github.com" 27 | - name: Install dependencies 28 | run: yarn install --check-files --frozen-lockfile 29 | - name: release 30 | run: npx projen release 31 | - name: Check for new commits 32 | id: git_remote 33 | run: echo ::set-output name=latest_commit::"$(git ls-remote origin -h ${{ github.ref }} | cut -f1)" 34 | - name: Upload artifact 35 | if: ${{ steps.git_remote.outputs.latest_commit == github.sha }} 36 | uses: actions/upload-artifact@v2.1.1 37 | with: 38 | name: build-artifact 39 | path: dist 40 | release_github: 41 | name: Publish to GitHub Releases 42 | needs: release 43 | runs-on: ubuntu-latest 44 | permissions: 45 | contents: write 46 | if: needs.release.outputs.latest_commit == github.sha 47 | steps: 48 | - uses: actions/setup-node@v3 49 | with: 50 | node-version: 14.x 51 | - name: Download build artifacts 52 | uses: actions/download-artifact@v3 53 | with: 54 | name: build-artifact 55 | path: dist 56 | - name: Release 57 | run: errout=$(mktemp); gh release create $(cat dist/releasetag.txt) -R $GITHUB_REPOSITORY -F dist/changelog.md -t $(cat dist/releasetag.txt) --target $GITHUB_REF 2> $errout && true; exitcode=$?; if [ $exitcode -ne 0 ] && ! grep -q "Release.tag_name already exists" $errout; then cat $errout; exit $exitcode; fi 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | GITHUB_REPOSITORY: ${{ github.repository }} 61 | GITHUB_REF: ${{ github.ref }} 62 | -------------------------------------------------------------------------------- /.github/workflows/upgrade-main.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: upgrade-main 4 | on: 5 | workflow_dispatch: {} 6 | schedule: 7 | - cron: 0 0 * * * 8 | jobs: 9 | upgrade: 10 | name: Upgrade 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: read 14 | outputs: 15 | patch_created: ${{ steps.create_patch.outputs.patch_created }} 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | with: 20 | ref: main 21 | - name: Install dependencies 22 | run: yarn install --check-files --frozen-lockfile 23 | - name: Upgrade dependencies 24 | run: npx projen upgrade 25 | - id: create_patch 26 | name: Find mutations 27 | run: |- 28 | git add . 29 | git diff --staged --patch --exit-code > .repo.patch || echo "::set-output name=patch_created::true" 30 | - if: steps.create_patch.outputs.patch_created 31 | name: Upload patch 32 | uses: actions/upload-artifact@v2 33 | with: 34 | name: .repo.patch 35 | path: .repo.patch 36 | pr: 37 | name: Create Pull Request 38 | needs: upgrade 39 | runs-on: ubuntu-latest 40 | permissions: 41 | contents: write 42 | pull-requests: write 43 | if: ${{ needs.upgrade.outputs.patch_created }} 44 | steps: 45 | - name: Checkout 46 | uses: actions/checkout@v3 47 | with: 48 | token: ${{ secrets.PROJEN_GITHUB_TOKEN }} 49 | ref: main 50 | - name: Download patch 51 | uses: actions/download-artifact@v3 52 | with: 53 | name: .repo.patch 54 | path: ${{ runner.temp }} 55 | - name: Apply patch 56 | run: '[ -s ${{ runner.temp }}/.repo.patch ] && git apply ${{ runner.temp }}/.repo.patch || echo "Empty patch. Skipping."' 57 | - name: Set git identity 58 | run: |- 59 | git config user.name "github-actions" 60 | git config user.email "github-actions@github.com" 61 | - name: Create Pull Request 62 | id: create-pr 63 | uses: peter-evans/create-pull-request@v3 64 | with: 65 | token: ${{ secrets.PROJEN_GITHUB_TOKEN }} 66 | commit-message: |- 67 | chore(deps): upgrade dependencies 68 | 69 | Upgrades project dependencies. See details in [workflow run]. 70 | 71 | [Workflow Run]: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} 72 | 73 | ------ 74 | 75 | *Automatically created by projen via the "upgrade-main" workflow* 76 | branch: github-actions/upgrade-main 77 | title: "chore(deps): upgrade dependencies" 78 | body: |- 79 | Upgrades project dependencies. See details in [workflow run]. 80 | 81 | [Workflow Run]: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} 82 | 83 | ------ 84 | 85 | *Automatically created by projen via the "upgrade-main" workflow* 86 | author: github-actions 87 | committer: github-actions 88 | signoff: true 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | !/.gitattributes 3 | !/.projen/tasks.json 4 | !/.projen/deps.json 5 | !/.projen/files.json 6 | !/.github/workflows/pull-request-lint.yml 7 | !/package.json 8 | !/LICENSE 9 | !/.npmignore 10 | logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | lerna-debug.log* 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | lib-cov 22 | coverage 23 | *.lcov 24 | .nyc_output 25 | build/Release 26 | node_modules/ 27 | jspm_packages/ 28 | *.tsbuildinfo 29 | .eslintcache 30 | *.tgz 31 | .yarn-integrity 32 | .cache 33 | !/.projenrc.js 34 | /test-reports/ 35 | junit.xml 36 | /coverage/ 37 | !/.github/workflows/build.yml 38 | /dist/changelog.md 39 | /dist/version.txt 40 | !/.github/workflows/release.yml 41 | !/.mergify.yml 42 | !/.github/workflows/upgrade-main.yml 43 | !/.github/pull_request_template.md 44 | !/test/ 45 | !/tsconfig.json 46 | !/tsconfig.dev.json 47 | !/src/ 48 | /lib 49 | /dist/ 50 | !/.eslintrc.json 51 | /assets/ 52 | !/cdk.json 53 | /cdk.out/ 54 | .cdk.staging/ 55 | .parcel-cache/ 56 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | queue_rules: 4 | - name: default 5 | conditions: 6 | - "#approved-reviews-by>=1" 7 | - -label~=(do-not-merge) 8 | - status-success=build 9 | pull_request_rules: 10 | - name: Automatic merge on approval and successful build 11 | actions: 12 | delete_head_branch: {} 13 | queue: 14 | method: squash 15 | name: default 16 | commit_message_template: |- 17 | {{ title }} (#{{ number }}) 18 | 19 | {{ body }} 20 | conditions: 21 | - "#approved-reviews-by>=1" 22 | - -label~=(do-not-merge) 23 | - status-success=build 24 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | /.projen/ 3 | /test-reports/ 4 | junit.xml 5 | /coverage/ 6 | /dist/changelog.md 7 | /dist/version.txt 8 | /.mergify.yml 9 | /test/ 10 | /tsconfig.dev.json 11 | /src/ 12 | !/lib/ 13 | !/lib/**/*.js 14 | !/lib/**/*.d.ts 15 | dist 16 | /tsconfig.json 17 | /.github/ 18 | /.vscode/ 19 | /.idea/ 20 | /.projenrc.js 21 | tsconfig.tsbuildinfo 22 | /.eslintrc.json 23 | !/assets/ 24 | cdk.out/ 25 | .cdk.staging/ 26 | -------------------------------------------------------------------------------- /.projen/deps.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": [ 3 | { 4 | "name": "@functionless/language-service", 5 | "type": "build" 6 | }, 7 | { 8 | "name": "@functionless/projen", 9 | "type": "build" 10 | }, 11 | { 12 | "name": "@types/change-case", 13 | "type": "build" 14 | }, 15 | { 16 | "name": "@types/jest", 17 | "type": "build" 18 | }, 19 | { 20 | "name": "@types/lodash.sortby", 21 | "type": "build" 22 | }, 23 | { 24 | "name": "@types/node", 25 | "version": "^14", 26 | "type": "build" 27 | }, 28 | { 29 | "name": "@typescript-eslint/eslint-plugin", 30 | "version": "^5", 31 | "type": "build" 32 | }, 33 | { 34 | "name": "@typescript-eslint/parser", 35 | "version": "^5", 36 | "type": "build" 37 | }, 38 | { 39 | "name": "aws-cdk", 40 | "version": "^2.1.0", 41 | "type": "build" 42 | }, 43 | { 44 | "name": "esbuild", 45 | "type": "build" 46 | }, 47 | { 48 | "name": "eslint-config-prettier", 49 | "type": "build" 50 | }, 51 | { 52 | "name": "eslint-import-resolver-node", 53 | "type": "build" 54 | }, 55 | { 56 | "name": "eslint-import-resolver-typescript", 57 | "type": "build" 58 | }, 59 | { 60 | "name": "eslint-plugin-import", 61 | "type": "build" 62 | }, 63 | { 64 | "name": "eslint-plugin-prettier", 65 | "type": "build" 66 | }, 67 | { 68 | "name": "eslint", 69 | "version": "^8", 70 | "type": "build" 71 | }, 72 | { 73 | "name": "jest", 74 | "type": "build" 75 | }, 76 | { 77 | "name": "jest-junit", 78 | "version": "^13", 79 | "type": "build" 80 | }, 81 | { 82 | "name": "json-schema", 83 | "type": "build" 84 | }, 85 | { 86 | "name": "npm-check-updates", 87 | "version": "^15", 88 | "type": "build" 89 | }, 90 | { 91 | "name": "prettier", 92 | "type": "build" 93 | }, 94 | { 95 | "name": "projen", 96 | "type": "build" 97 | }, 98 | { 99 | "name": "standard-version", 100 | "version": "^9", 101 | "type": "build" 102 | }, 103 | { 104 | "name": "ts-jest", 105 | "type": "build" 106 | }, 107 | { 108 | "name": "ts-node", 109 | "type": "build" 110 | }, 111 | { 112 | "name": "ts-patch", 113 | "type": "build" 114 | }, 115 | { 116 | "name": "typescript", 117 | "type": "build" 118 | }, 119 | { 120 | "name": "@aws-cdk/aws-appsync-alpha", 121 | "type": "runtime" 122 | }, 123 | { 124 | "name": "@aws-sdk/client-sfn", 125 | "type": "runtime" 126 | }, 127 | { 128 | "name": "@types/aws-lambda", 129 | "type": "runtime" 130 | }, 131 | { 132 | "name": "aws-cdk-lib", 133 | "version": "^2.1.0", 134 | "type": "runtime" 135 | }, 136 | { 137 | "name": "change-case", 138 | "type": "runtime" 139 | }, 140 | { 141 | "name": "constructs", 142 | "version": "^10.0.5", 143 | "type": "runtime" 144 | }, 145 | { 146 | "name": "functionless", 147 | "type": "runtime" 148 | }, 149 | { 150 | "name": "lodash.sortby", 151 | "type": "runtime" 152 | }, 153 | { 154 | "name": "typesafe-dynamodb", 155 | "type": "runtime" 156 | } 157 | ], 158 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 159 | } 160 | -------------------------------------------------------------------------------- /.projen/files.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | ".eslintrc.json", 4 | ".gitattributes", 5 | ".github/pull_request_template.md", 6 | ".github/workflows/build.yml", 7 | ".github/workflows/pull-request-lint.yml", 8 | ".github/workflows/release.yml", 9 | ".github/workflows/upgrade-main.yml", 10 | ".gitignore", 11 | ".mergify.yml", 12 | ".npmignore", 13 | ".projen/deps.json", 14 | ".projen/files.json", 15 | ".projen/tasks.json", 16 | "cdk.json", 17 | "LICENSE", 18 | "tsconfig.dev.json", 19 | "tsconfig.json" 20 | ], 21 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 22 | } 23 | -------------------------------------------------------------------------------- /.projen/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "build": { 4 | "name": "build", 5 | "description": "Full release build", 6 | "steps": [ 7 | { 8 | "spawn": "default" 9 | }, 10 | { 11 | "spawn": "pre-compile" 12 | }, 13 | { 14 | "spawn": "compile" 15 | }, 16 | { 17 | "spawn": "post-compile" 18 | }, 19 | { 20 | "spawn": "test" 21 | }, 22 | { 23 | "spawn": "package" 24 | } 25 | ] 26 | }, 27 | "bump": { 28 | "name": "bump", 29 | "description": "Bumps version based on latest git tag and generates a changelog entry", 30 | "env": { 31 | "OUTFILE": "package.json", 32 | "CHANGELOG": "dist/changelog.md", 33 | "BUMPFILE": "dist/version.txt", 34 | "RELEASETAG": "dist/releasetag.txt", 35 | "RELEASE_TAG_PREFIX": "" 36 | }, 37 | "steps": [ 38 | { 39 | "builtin": "release/bump-version" 40 | } 41 | ], 42 | "condition": "! git log --oneline -1 | grep -q \"chore(release):\"" 43 | }, 44 | "bundle": { 45 | "name": "bundle", 46 | "description": "Prepare assets" 47 | }, 48 | "clobber": { 49 | "name": "clobber", 50 | "description": "hard resets to HEAD of origin and cleans the local repo", 51 | "env": { 52 | "BRANCH": "$(git branch --show-current)" 53 | }, 54 | "steps": [ 55 | { 56 | "exec": "git checkout -b scratch", 57 | "name": "save current HEAD in \"scratch\" branch" 58 | }, 59 | { 60 | "exec": "git checkout $BRANCH" 61 | }, 62 | { 63 | "exec": "git fetch origin", 64 | "name": "fetch latest changes from origin" 65 | }, 66 | { 67 | "exec": "git reset --hard origin/$BRANCH", 68 | "name": "hard reset to origin commit" 69 | }, 70 | { 71 | "exec": "git clean -fdx", 72 | "name": "clean all untracked files" 73 | }, 74 | { 75 | "say": "ready to rock! (unpushed commits are under the \"scratch\" branch)" 76 | } 77 | ], 78 | "condition": "git diff --exit-code > /dev/null" 79 | }, 80 | "compile": { 81 | "name": "compile", 82 | "description": "Only compile" 83 | }, 84 | "default": { 85 | "name": "default", 86 | "description": "Synthesize project files", 87 | "steps": [ 88 | { 89 | "exec": "node .projenrc.js" 90 | } 91 | ] 92 | }, 93 | "deploy": { 94 | "name": "deploy", 95 | "description": "Deploys your CDK app to the AWS cloud", 96 | "steps": [ 97 | { 98 | "exec": "cdk deploy" 99 | } 100 | ] 101 | }, 102 | "deploy-example": { 103 | "name": "deploy-example", 104 | "steps": [ 105 | { 106 | "exec": "cdk deploy --app \"npx ts-node src/examples/test-stack.ts\"" 107 | } 108 | ] 109 | }, 110 | "destroy": { 111 | "name": "destroy", 112 | "description": "Destroys your cdk app in the AWS cloud", 113 | "steps": [ 114 | { 115 | "exec": "cdk destroy" 116 | } 117 | ] 118 | }, 119 | "diff": { 120 | "name": "diff", 121 | "description": "Diffs the currently deployed app against your code", 122 | "steps": [ 123 | { 124 | "exec": "cdk diff" 125 | } 126 | ] 127 | }, 128 | "eject": { 129 | "name": "eject", 130 | "description": "Remove projen from the project", 131 | "env": { 132 | "PROJEN_EJECTING": "true" 133 | }, 134 | "steps": [ 135 | { 136 | "spawn": "default" 137 | } 138 | ] 139 | }, 140 | "eslint": { 141 | "name": "eslint", 142 | "description": "Runs eslint against the codebase", 143 | "steps": [ 144 | { 145 | "exec": "eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern src test build-tools .projenrc.js" 146 | } 147 | ] 148 | }, 149 | "package": { 150 | "name": "package", 151 | "description": "Creates the distribution package", 152 | "steps": [ 153 | { 154 | "exec": "mkdir -p dist/js" 155 | }, 156 | { 157 | "exec": "mv $(npm pack) dist/js/" 158 | } 159 | ] 160 | }, 161 | "post-compile": { 162 | "name": "post-compile", 163 | "description": "Runs after successful compilation", 164 | "steps": [ 165 | { 166 | "spawn": "synth:silent" 167 | } 168 | ] 169 | }, 170 | "post-upgrade": { 171 | "name": "post-upgrade", 172 | "description": "Runs after upgrading dependencies" 173 | }, 174 | "pre-compile": { 175 | "name": "pre-compile", 176 | "description": "Prepare the project for compilation" 177 | }, 178 | "prepare": { 179 | "name": "prepare", 180 | "steps": [ 181 | { 182 | "exec": "ts-patch install -s" 183 | } 184 | ] 185 | }, 186 | "prerelease": { 187 | "name": "prerelease", 188 | "steps": [ 189 | { 190 | "exec": "npm run prepare && npm run compile && npm run package" 191 | } 192 | ] 193 | }, 194 | "release": { 195 | "name": "release", 196 | "description": "Prepare a release from \"main\" branch", 197 | "env": { 198 | "RELEASE": "true" 199 | }, 200 | "steps": [ 201 | { 202 | "exec": "rm -fr dist" 203 | }, 204 | { 205 | "spawn": "bump" 206 | }, 207 | { 208 | "spawn": "build" 209 | }, 210 | { 211 | "spawn": "unbump" 212 | }, 213 | { 214 | "exec": "git diff --ignore-space-at-eol --exit-code" 215 | } 216 | ] 217 | }, 218 | "synth": { 219 | "name": "synth", 220 | "description": "Synthesizes your cdk app into cdk.out", 221 | "steps": [ 222 | { 223 | "exec": "cdk synth" 224 | } 225 | ] 226 | }, 227 | "synth:silent": { 228 | "name": "synth:silent", 229 | "description": "Synthesizes your cdk app into cdk.out and suppresses the template in stdout (part of \"yarn build\")", 230 | "steps": [ 231 | { 232 | "exec": "cdk synth -q" 233 | } 234 | ] 235 | }, 236 | "test": { 237 | "name": "test", 238 | "description": "Run tests", 239 | "steps": [ 240 | { 241 | "exec": "jest --passWithNoTests --all --updateSnapshot" 242 | }, 243 | { 244 | "spawn": "eslint" 245 | } 246 | ] 247 | }, 248 | "test:update": { 249 | "name": "test:update", 250 | "description": "Update jest snapshots", 251 | "steps": [ 252 | { 253 | "exec": "jest --updateSnapshot" 254 | } 255 | ] 256 | }, 257 | "test:watch": { 258 | "name": "test:watch", 259 | "description": "Run jest in watch mode", 260 | "steps": [ 261 | { 262 | "exec": "jest --watch" 263 | } 264 | ] 265 | }, 266 | "unbump": { 267 | "name": "unbump", 268 | "description": "Restores version to 0.0.0", 269 | "env": { 270 | "OUTFILE": "package.json", 271 | "CHANGELOG": "dist/changelog.md", 272 | "BUMPFILE": "dist/version.txt", 273 | "RELEASETAG": "dist/releasetag.txt", 274 | "RELEASE_TAG_PREFIX": "" 275 | }, 276 | "steps": [ 277 | { 278 | "builtin": "release/reset-version" 279 | } 280 | ] 281 | }, 282 | "upgrade": { 283 | "name": "upgrade", 284 | "description": "upgrade dependencies", 285 | "env": { 286 | "CI": "0" 287 | }, 288 | "steps": [ 289 | { 290 | "exec": "yarn upgrade npm-check-updates" 291 | }, 292 | { 293 | "exec": "npm-check-updates --dep dev --upgrade --target=minor" 294 | }, 295 | { 296 | "exec": "npm-check-updates --dep optional --upgrade --target=minor" 297 | }, 298 | { 299 | "exec": "npm-check-updates --dep peer --upgrade --target=minor" 300 | }, 301 | { 302 | "exec": "npm-check-updates --dep prod --upgrade --target=minor" 303 | }, 304 | { 305 | "exec": "npm-check-updates --dep bundle --upgrade --target=minor" 306 | }, 307 | { 308 | "exec": "yarn install --check-files" 309 | }, 310 | { 311 | "exec": "yarn upgrade" 312 | }, 313 | { 314 | "exec": "npx projen" 315 | }, 316 | { 317 | "spawn": "post-upgrade" 318 | } 319 | ] 320 | }, 321 | "watch": { 322 | "name": "watch", 323 | "description": "Watches changes in your source code and rebuilds and deploys to the current account", 324 | "steps": [ 325 | { 326 | "exec": "cdk deploy --hotswap" 327 | }, 328 | { 329 | "exec": "cdk watch" 330 | } 331 | ] 332 | } 333 | }, 334 | "env": { 335 | "PATH": "$(npx -c \"node -e \\\"console.log(process.env.PATH)\\\"\")" 336 | }, 337 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 338 | } 339 | -------------------------------------------------------------------------------- /.projenrc.js: -------------------------------------------------------------------------------- 1 | const { FunctionlessProject } = require("@functionless/projen"); 2 | const project = new FunctionlessProject({ 3 | cdkVersion: "2.1.0", 4 | defaultReleaseBranch: "main", 5 | devDeps: [ 6 | "@functionless/projen", 7 | "@types/change-case", 8 | "@types/lodash.sortby", 9 | ], 10 | package: true, 11 | appEntrypoint: "./src/index.ts", 12 | name: "dynamodb-mass-update", 13 | eslintOptions: { 14 | prettier: true, 15 | quotes: "double", 16 | }, 17 | scripts: { 18 | "deploy-example": 19 | 'cdk deploy --app "npx ts-node src/examples/test-stack.ts"', 20 | prerelease: "npm run prepare && npm run compile && npm run package", 21 | }, 22 | description: 23 | "Functionless-based mini-framework for DynamoDB migrations in AWS CDK.", 24 | deps: [ 25 | "@aws-sdk/client-sfn", 26 | "@types/aws-lambda", 27 | "change-case", 28 | "lodash.sortby", 29 | ], 30 | release: true, 31 | packageName: "@dynobase/dynamodb-migrations", 32 | }); 33 | 34 | project.synth(); 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dynamodb-migrations 2 | 3 | > **This repo is heavily in progress!** Readme describes **desired** contract and functionality. Please do not try using it **yet!**. I'm not even sure if it's a good idea. 4 | 5 | [Functionless](https://github.com/functionless/functionless)-based mini-framework for DynamoDB migrations in AWS CDK. `dynamodb-migrations` leverages [Step Functions](https://aws.amazon.com/step-functions/) to enable massively parallel reads and writes to DynamoDB making your migrations faster. 6 | 7 | `dynamodb-migrations` uses _"Migrations as Infrastructure"_ approach - each migration ends up being a separate State Machine, each one of them is deployed as a separate Nested Stack. 8 | 9 | Migrations are ran as a part of CloudFormation deployment cycle - each migration, provisioned as a state machine, gets invoked via CloudFormation Custom Resource which is also part of the stack itself. 10 | 11 | ```mermaid 12 | sequenceDiagram 13 | participant User 14 | participant CloudFormation 15 | User-->>+CloudFormation: Start deployment with a migration via `cdk deploy` 16 | Note right of CloudFormation: Update/Create Stack 17 | CloudFormation->>Cloud: Provision Application Resources 18 | CloudFormation->>+Step Functions: Provision Migration State Machine as a Nested Stack 19 | Step Functions->>-CloudFormation: Provisioned 20 | CloudFormation->>+Custom Resource: Perform Migrations by starting executions 21 | Custom Resource->>+Migrations History Table: Check which migrations have been already ran 22 | Migrations History Table->>-Custom Resource: Here are migrations ran in the past 23 | Note Right of Custom Resource: There's one new migration that hasn't been ran yet 24 | Custom Resource-->>Step Functions: Run State Machine 25 | Note left of Step Functions: State machine is an actual definition of migration 26 | par Perform migration 27 | Step Functions->>+DynamoDB Table: Update / Delete Items 28 | end 29 | par Periodically check if migration is done 30 | Custom Resource->>+Step Functions: Is done? 31 | and State Machine Finished Execution 32 | Custom Resource->>Migrations History Table: Store info about completed migration 33 | Custom Resource->>-CloudFormation: End 34 | end 35 | ``` 36 | 37 | ### Questions to answer / notes 38 | 39 | - Why Step Function is better than just CloudFormation Custom Resource running some business logic? 40 | - Step Function can run up to a **year**. Singular lambda function - 15 minutes. 41 | - What about throtting? Maybe it's easier thanks to Step Functions? Or maybe it's not? Think about token bucket. 42 | - Is it possible to track migration progress? Probably some reporting can be added. 43 | - Excessive resource provisioning might be a problem. 44 | - Each state machine transition is a cost so one migration can be expensive. 45 | - Maybe migrations table is not needed since each state machine is persistent? 46 | - It's definitely more convenient to have a table. State machines can be disposed after running. 47 | 48 | ## Installation / Getting Started 49 | 50 | 1. Install `dynamodb-migrations`: 51 | 52 | ```bash 53 | npm i @dynobase/dynamodb-migrations --save 54 | ``` 55 | 56 | 2. Include `Migrations` construct in the stack where your DynamoDB Table is located. 57 | 58 | ```ts 59 | import { MigrationsManager } from '@dynobase/dynamodb-migrations'; 60 | 61 | ... 62 | 63 | new MigrationsManager(this, 'MigrationsManager', { 64 | migrationsDir: './migrations', 65 | }); 66 | ``` 67 | 68 | This will create an additional DynamoDB table that will be used to store the migrations history. 69 | 70 | 4. Write an actual migration. 71 | 72 | Create file called `20220101-add-attribute.ts` in the `migrationsDir` and paste following contents. This migration will add a new attribute called `migrated` to every item in the table. 73 | 74 | ```ts 75 | import { $AWS } from "functionless"; 76 | import { unmarshall, marshall } from "typesafe-dynamodb/lib/marshall"; 77 | import { Migration, MigrationFunction } from "../.."; 78 | 79 | const tableArn = 80 | "arn:aws:dynamodb:us-east-1:085108115628:table/TestStack-TableCD117FA1-ZVV3ZWUOWPO"; 81 | 82 | export const migration: MigrationFunction = (scope, migrationName) => { 83 | const migrationDefinition = new Migration(scope, migrationName, { 84 | tableArn, 85 | migrationName, 86 | }); 87 | 88 | const table = migrationDefinition.table; 89 | 90 | // Actual migration code goes here. 91 | // For each item in the table 92 | migrationDefinition.scan(async ({ result }) => { 93 | for (const i of result.Items as any[]) { 94 | // Do the following 95 | await $AWS.DynamoDB.PutItem({ 96 | Table: table, 97 | // Add migratedAt attribute to the item 98 | Item: marshall({ ...unmarshall(i), migratedAt: Date.now() }), 99 | }); 100 | } 101 | }); 102 | 103 | return migrationDefinition; 104 | }; 105 | ``` 106 | 107 | And that's it! This migration will be executed as a part of the next `cdk deploy` command fully under CloudFormation's control. After successfully running it, the migration will be marked as `migrated` in the migrations history table. 108 | 109 | ## Todo 110 | 111 | - [x] Do not re-run previously applied migrations 112 | - [x] Query and Scan functionality 113 | - [x] Storing `executionArn` in the migrations history table 114 | - [ ] Add an option for disposing state machines after running migrations 115 | - [ ] Distinguish up/down migrations - if rollback is being performed, then Custom Resource should call `down` migration which will reverse the effect of the `up` migration 116 | - [ ] Dry runs 117 | - [ ] CLI for creating new migration files 118 | - [ ] Better contract for writing migrations/semantics 119 | - [ ] Reporting progress 120 | - [ ] Package and publish to NPM 121 | - [ ] More examples 122 | 123 | ## Limitations 124 | 125 | - Migrations cannot be running for longer than two hours, that's a custom resource provisioning limitation 126 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node -P tsconfig.json --prefer-ts-exts src/app.ts", 3 | "output": "cdk.out", 4 | "build": "npx projen bundle", 5 | "watch": { 6 | "include": [ 7 | "src/**/*.ts", 8 | "test/**/*.ts" 9 | ], 10 | "exclude": [ 11 | "README.md", 12 | "cdk*.json", 13 | "**/*.d.ts", 14 | "**/*.js", 15 | "tsconfig.json", 16 | "package*.json", 17 | "yarn.lock", 18 | "node_modules" 19 | ] 20 | }, 21 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dynobase/dynamodb-migrations", 3 | "description": "Functionless-based mini-framework for DynamoDB migrations in AWS CDK.", 4 | "scripts": { 5 | "build": "npx projen build", 6 | "bump": "npx projen bump", 7 | "bundle": "npx projen bundle", 8 | "clobber": "npx projen clobber", 9 | "compile": "npx projen compile", 10 | "default": "npx projen default", 11 | "deploy": "npx projen deploy", 12 | "deploy-example": "npx projen deploy-example", 13 | "destroy": "npx projen destroy", 14 | "diff": "npx projen diff", 15 | "eject": "npx projen eject", 16 | "eslint": "npx projen eslint", 17 | "package": "npx projen package", 18 | "post-compile": "npx projen post-compile", 19 | "post-upgrade": "npx projen post-upgrade", 20 | "pre-compile": "npx projen pre-compile", 21 | "prepare": "npx projen prepare", 22 | "prerelease": "npx projen prerelease", 23 | "release": "npx projen release", 24 | "synth": "npx projen synth", 25 | "synth:silent": "npx projen synth:silent", 26 | "test": "npx projen test", 27 | "test:update": "npx projen test:update", 28 | "test:watch": "npx projen test:watch", 29 | "unbump": "npx projen unbump", 30 | "upgrade": "npx projen upgrade", 31 | "watch": "npx projen watch", 32 | "projen": "npx projen" 33 | }, 34 | "devDependencies": { 35 | "@functionless/language-service": "^0.0.4", 36 | "@functionless/projen": "^0.0.8", 37 | "@types/change-case": "^2.3.1", 38 | "@types/jest": "^28.1.6", 39 | "@types/lodash.sortby": "^4.7.7", 40 | "@types/node": "^14", 41 | "@typescript-eslint/eslint-plugin": "^5", 42 | "@typescript-eslint/parser": "^5", 43 | "aws-cdk": "^2.1.0", 44 | "esbuild": "^0.14.53", 45 | "eslint": "^8", 46 | "eslint-config-prettier": "^8.5.0", 47 | "eslint-import-resolver-node": "^0.3.6", 48 | "eslint-import-resolver-typescript": "^3.4.0", 49 | "eslint-plugin-import": "^2.26.0", 50 | "eslint-plugin-prettier": "^4.2.1", 51 | "jest": "^28.1.3", 52 | "jest-junit": "^13", 53 | "json-schema": "^0.4.0", 54 | "npm-check-updates": "^15", 55 | "prettier": "^2.7.1", 56 | "projen": "^0.61.1", 57 | "standard-version": "^9", 58 | "ts-jest": "^28.0.7", 59 | "ts-node": "^10.9.1", 60 | "ts-patch": "^2.0.1", 61 | "typescript": "^4.7.4" 62 | }, 63 | "dependencies": { 64 | "@aws-cdk/aws-appsync-alpha": "^2.36.0-alpha.0", 65 | "@aws-sdk/client-sfn": "^3.145.0", 66 | "@types/aws-lambda": "^8.10.102", 67 | "aws-cdk-lib": "^2.1.0", 68 | "change-case": "^4.1.2", 69 | "constructs": "^10.0.5", 70 | "functionless": "0.21.1", 71 | "lodash.sortby": "^4.7.0", 72 | "typesafe-dynamodb": "^0.2.2" 73 | }, 74 | "license": "Apache-2.0", 75 | "version": "0.0.0", 76 | "jest": { 77 | "testMatch": [ 78 | "/src/**/__tests__/**/*.ts?(x)", 79 | "/(test|src)/**/*(*.)@(spec|test).ts?(x)" 80 | ], 81 | "clearMocks": true, 82 | "collectCoverage": true, 83 | "coverageReporters": [ 84 | "json", 85 | "lcov", 86 | "clover", 87 | "cobertura", 88 | "text" 89 | ], 90 | "coverageDirectory": "coverage", 91 | "coveragePathIgnorePatterns": [ 92 | "/node_modules/" 93 | ], 94 | "testPathIgnorePatterns": [ 95 | "/node_modules/" 96 | ], 97 | "watchPathIgnorePatterns": [ 98 | "/node_modules/" 99 | ], 100 | "reporters": [ 101 | "default", 102 | [ 103 | "jest-junit", 104 | { 105 | "outputDirectory": "test-reports" 106 | } 107 | ] 108 | ], 109 | "preset": "ts-jest", 110 | "globals": { 111 | "ts-jest": { 112 | "tsconfig": "tsconfig.dev.json" 113 | } 114 | } 115 | }, 116 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 117 | } -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import app from "./examples/test-stack"; 2 | 3 | export * from "./migrations-manager"; 4 | export * from "./migration"; 5 | 6 | export default app; 7 | -------------------------------------------------------------------------------- /src/custom-resource-is-migration-complete.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DescribeExecutionCommand, 3 | ExecutionStatus, 4 | SFNClient, 5 | } from "@aws-sdk/client-sfn"; 6 | import { CloudFormationCustomResourceEvent } from "aws-lambda"; 7 | import { Construct } from "constructs"; 8 | import { $AWS, Function, Table } from "functionless"; 9 | import { marshall, unmarshall } from "typesafe-dynamodb/lib/marshall"; 10 | import { MigrationHistoryItem } from "./migrations-manager"; 11 | 12 | type MigrationIdStateMachineArnPair = { 13 | migrationId: string; 14 | stateMachineArn: string; 15 | }; 16 | 17 | export type CustomResourceIsMigrationCompleteCheckerProps = { 18 | migrationIdStateMachinePairs: MigrationIdStateMachineArnPair[]; 19 | migrationsHistoryTable: Table; 20 | }; 21 | 22 | export type ReturnType = { IsComplete: boolean }; 23 | 24 | export default class CustomResourceIsMigrationCompleteChecker extends Construct { 25 | public readonly function: Function< 26 | CloudFormationCustomResourceEvent, 27 | ReturnType 28 | >; 29 | constructor( 30 | scope: Construct, 31 | id: string, 32 | { 33 | migrationsHistoryTable, 34 | migrationIdStateMachinePairs, 35 | }: CustomResourceIsMigrationCompleteCheckerProps 36 | ) { 37 | super(scope, id); 38 | 39 | let allDone = true; 40 | 41 | this.function = new Function( 42 | scope, 43 | `${id}-MigrationsChecker`, 44 | async (event: CloudFormationCustomResourceEvent): Promise => { 45 | console.log(event); 46 | 47 | const client = new SFNClient({}); 48 | 49 | for (const migration of migrationIdStateMachinePairs) { 50 | const migrationEntry = await $AWS.DynamoDB.GetItem({ 51 | Table: migrationsHistoryTable, 52 | Key: marshall({ id: migration.migrationId }), 53 | }); 54 | 55 | if (!migrationEntry.Item) { 56 | throw new Error( 57 | `Failed to find migration entry for migrationId: ${migration.migrationId}` 58 | ); 59 | } 60 | 61 | const item = unmarshall(migrationEntry.Item); 62 | 63 | if ( 64 | item.status === "ABORTED" || 65 | item.status === "FAILED" || 66 | item.status === "SUCCEEDED" || 67 | item.status === "TIMED_OUT" 68 | ) { 69 | continue; 70 | } 71 | 72 | const command = new DescribeExecutionCommand({ 73 | executionArn: item.executionArn, 74 | }); 75 | const response = await client.send(command); 76 | 77 | console.log({ migration, response }); 78 | 79 | if (response.status === "RUNNING") { 80 | allDone = false; 81 | } 82 | 83 | await $AWS.DynamoDB.PutItem({ 84 | Table: migrationsHistoryTable, 85 | Item: marshall({ 86 | id: migration.migrationId, 87 | startedAt: response.startDate?.toISOString()!, 88 | executionArn: response.executionArn!, 89 | status: response.status as ExecutionStatus, 90 | endedAt: response.stopDate?.toISOString(), 91 | }), 92 | }); 93 | } 94 | 95 | return { IsComplete: allDone }; 96 | } 97 | ); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/custom-resource-migrations-runner.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExecutionStatus, 3 | SFNClient, 4 | StartExecutionCommand, 5 | } from "@aws-sdk/client-sfn"; 6 | import { PolicyStatement } from "aws-cdk-lib/aws-iam"; 7 | import { 8 | CloudFormationCustomResourceEvent, 9 | CloudFormationCustomResourceResponse, 10 | } from "aws-lambda"; 11 | import { Construct } from "constructs"; 12 | import { $AWS, Function, Table } from "functionless"; 13 | import sortBy from "lodash.sortby"; 14 | import { marshall } from "typesafe-dynamodb/lib/marshall"; 15 | import { MigrationHistoryItem } from "./migrations-manager"; 16 | 17 | type MigrationIdStateMachineArnPair = { 18 | migrationId: string; 19 | stateMachineArn: string; 20 | }; 21 | 22 | export type CustomResourceMigrationsRunnerProps = { 23 | migrationIdStateMachinePairs: MigrationIdStateMachineArnPair[]; 24 | migrationsHistoryTable: Table; 25 | }; 26 | 27 | export default class CustomResourceMigrationsRunner extends Construct { 28 | public readonly function: Function< 29 | CloudFormationCustomResourceEvent, 30 | CloudFormationCustomResourceResponse 31 | >; 32 | constructor( 33 | scope: Construct, 34 | id: string, 35 | { 36 | migrationsHistoryTable, 37 | migrationIdStateMachinePairs, 38 | }: CustomResourceMigrationsRunnerProps 39 | ) { 40 | super(scope, id); 41 | 42 | this.function = new Function( 43 | scope, 44 | `${id}-MigrationsRunner`, 45 | async ( 46 | event: CloudFormationCustomResourceEvent 47 | ): Promise => { 48 | console.log(event); 49 | 50 | const client = new SFNClient({}); 51 | 52 | try { 53 | const storedMigrations = await $AWS.DynamoDB.Scan({ 54 | Table: migrationsHistoryTable, 55 | }); 56 | 57 | console.log({ storedMigrations, migrationIdStateMachinePairs }); 58 | 59 | const migrationsToRun = sortBy( 60 | migrationIdStateMachinePairs.filter( 61 | (migrationStateMachinePair) => 62 | !(storedMigrations.Items ?? []).find( 63 | (storedMigration) => 64 | storedMigration.id.S === 65 | migrationStateMachinePair.migrationId 66 | ) 67 | ), 68 | // migrationID starts with date 69 | "migrationId" 70 | ); 71 | 72 | // Run migrations sequentially 73 | for (const migration of migrationsToRun) { 74 | // todo: Depending on the cloudformation transition (success/rollback) we could either use Up or Down state machine 75 | const command = new StartExecutionCommand({ 76 | stateMachineArn: migration.stateMachineArn, 77 | }); 78 | const response = await client.send(command); 79 | 80 | console.log({ migration, response }); 81 | 82 | await $AWS.DynamoDB.PutItem({ 83 | Table: migrationsHistoryTable, 84 | Item: marshall({ 85 | id: migration.migrationId, 86 | status: "RUNNING" as ExecutionStatus, 87 | startedAt: response.startDate?.toISOString()!, 88 | executionArn: response.executionArn!, 89 | }), 90 | }); 91 | } 92 | 93 | return { 94 | Status: "SUCCESS", 95 | LogicalResourceId: event.LogicalResourceId, 96 | PhysicalResourceId: "DYNAMODB_MIGRATIONS_MANAGER", 97 | StackId: event.StackId, 98 | RequestId: event.RequestId, 99 | }; 100 | } catch (error) { 101 | console.error({ error }); 102 | 103 | return { 104 | Status: "FAILED", 105 | Reason: (error as Error).message, 106 | LogicalResourceId: event.LogicalResourceId, 107 | PhysicalResourceId: "DYNAMODB_MIGRATIONS_MANAGER", 108 | StackId: event.StackId, 109 | RequestId: event.RequestId, 110 | }; 111 | } 112 | } 113 | ); 114 | 115 | // Allow custom resource to start execution of the migrations state machine 116 | this.function.resource.addToRolePolicy( 117 | new PolicyStatement({ 118 | actions: ["states:StartExecution"], 119 | resources: migrationIdStateMachinePairs.map((m) => m.stateMachineArn), 120 | }) 121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/examples/migrations/20220801-add-attribute.ts: -------------------------------------------------------------------------------- 1 | import { $AWS } from "functionless"; 2 | import { unmarshall, marshall } from "typesafe-dynamodb/lib/marshall"; 3 | import { Migration, MigrationFunction } from "../.."; 4 | 5 | const tableArn = 6 | "arn:aws:dynamodb:us-east-1:085108115628:table/TestStack-TableCD117FA1-ZVV3ZWUOWPO"; 7 | 8 | export const migration: MigrationFunction = (scope, migrationName) => { 9 | const migrationDefinition = new Migration(scope, migrationName, { 10 | tableArn, 11 | migrationName, 12 | }); 13 | 14 | const table = migrationDefinition.table; 15 | 16 | // Actual migration code goes here. 17 | // For each item in the table 18 | migrationDefinition.scan(async ({ result }) => { 19 | for (const i of result.Items as any[]) { 20 | // Do the following 21 | await $AWS.DynamoDB.PutItem({ 22 | Table: table, 23 | // Add migratedAt attribute to the item 24 | Item: marshall({ ...unmarshall(i), migratedAt: Date.now() }), 25 | }); 26 | } 27 | }); 28 | 29 | return migrationDefinition; 30 | }; 31 | -------------------------------------------------------------------------------- /src/examples/test-stack.ts: -------------------------------------------------------------------------------- 1 | import { App, CfnOutput, Stack } from "aws-cdk-lib"; 2 | import { AttributeType, BillingMode, Table } from "aws-cdk-lib/aws-dynamodb"; 3 | import { MigrationsManager } from "../"; 4 | 5 | const app = new App(); 6 | 7 | class TestStack extends Stack { 8 | constructor(scope: App, id: string) { 9 | super(scope, id); 10 | 11 | const table = new Table(this, "Table", { 12 | partitionKey: { 13 | name: "id", 14 | type: AttributeType.STRING, 15 | }, 16 | billingMode: BillingMode.PAY_PER_REQUEST, 17 | }); 18 | 19 | new MigrationsManager(this, "MigrationsManager", { 20 | migrationsDir: "./src/examples/migrations", 21 | }); 22 | 23 | new CfnOutput(this, "TableArn", { 24 | value: table.tableArn, 25 | }); 26 | } 27 | } 28 | 29 | new TestStack(app, "TestStack"); 30 | 31 | export default app; 32 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./migrations-manager"; 2 | export * from "./migration"; 3 | -------------------------------------------------------------------------------- /src/migration.ts: -------------------------------------------------------------------------------- 1 | import { CfnOutput, NestedStack, Duration } from "aws-cdk-lib"; 2 | import { Table as cdkTable } from "aws-cdk-lib/aws-dynamodb"; 3 | import { LogGroup, RetentionDays } from "aws-cdk-lib/aws-logs"; 4 | import { LogLevel } from "aws-cdk-lib/aws-stepfunctions"; 5 | import { camelCase } from "change-case"; 6 | import { Construct } from "constructs"; 7 | import { 8 | ITable, 9 | StepFunction, 10 | Table, 11 | Function, 12 | $AWS, 13 | $SFN, 14 | } from "functionless"; 15 | import { QueryInput, QueryOutput } from "typesafe-dynamodb/lib/query"; 16 | import { ScanInput, ScanOutput } from "typesafe-dynamodb/lib/scan"; 17 | 18 | export type MigrationProps = { 19 | /** 20 | * ARN of the table to migrate. 21 | */ 22 | tableArn: string; 23 | 24 | /** 25 | * Name of the migration 26 | */ 27 | migrationName: string; 28 | 29 | /** 30 | * Maximum time to wait for the migration to complete. 31 | * Defaults to 5 minutes. 32 | */ 33 | timeout?: Duration; 34 | 35 | /** 36 | * Description of the migration for documentation purposes. 37 | */ 38 | description?: string; 39 | }; 40 | 41 | export type TransformFunctionType = (input: { 42 | result: ScanOutput | QueryOutput; 43 | }) => Promise; 44 | 45 | export class Migration extends NestedStack { 46 | public readonly table: ITable; 47 | 48 | public readonly migrationName: string; 49 | 50 | public stateMachineArn?: CfnOutput; 51 | 52 | public readonly timeout: Duration; 53 | 54 | public readonly description?: string; 55 | 56 | constructor(scope: Construct, id: string, props: MigrationProps) { 57 | super(scope, id); 58 | 59 | this.description = props.description; 60 | this.timeout = props.timeout ?? Duration.minutes(5); 61 | this.migrationName = camelCase(props.migrationName.split(".")[0]); 62 | this.table = Table.fromTable( 63 | cdkTable.fromTableArn(this, "SubjectTable", props.tableArn) 64 | ); 65 | } 66 | 67 | public query( 68 | transformFn: TransformFunctionType, 69 | _options?: QueryInput 70 | ) { 71 | // "this" cannot be referenced in a Function. 72 | const table = this.table; 73 | 74 | const transformFunction = new Function( 75 | this, 76 | "MigrationCallbackFunction", 77 | transformFn 78 | ); 79 | 80 | const stateMachine = new StepFunction( 81 | this, 82 | "MigrationStateMachine", 83 | { 84 | stateMachineName: this.migrationName, 85 | timeout: this.timeout, 86 | logs: { 87 | destination: new LogGroup(this, "MigrationLogGroup", { 88 | retention: RetentionDays.ONE_WEEK, 89 | }), 90 | level: LogLevel.ALL, 91 | }, 92 | }, 93 | async () => { 94 | let lastEvaluatedKey; 95 | let firstRun = true; 96 | 97 | while (firstRun || lastEvaluatedKey) { 98 | firstRun = false; 99 | 100 | const result: QueryOutput = 101 | await $AWS.DynamoDB.Query({ 102 | Table: table, 103 | // Todo: figure out how to pass in options 104 | // KeyConditionExpression: options?.KeyConditionExpression, 105 | // FilterExpression: options?.FilterExpression, 106 | // AttributesToGet: options?.AttributesToGet, 107 | // ConsistentRead: options?.ConsistentRead, 108 | // KeyConditions: options?.KeyConditions, 109 | // QueryFilter: options?.QueryFilter, 110 | // IndexName: options?.IndexName, 111 | // ScanIndexForward: options?.ScanIndexForward, 112 | // Limit: options?.Limit, 113 | ExclusiveStartKey: lastEvaluatedKey, 114 | }); 115 | 116 | if (result.LastEvaluatedKey) { 117 | lastEvaluatedKey = result.LastEvaluatedKey; 118 | } 119 | 120 | await transformFunction({ result }); 121 | } 122 | } 123 | ); 124 | 125 | this.stateMachineArn = new CfnOutput(this, "StateMachineArn", { 126 | exportName: `${this.migrationName}StateMachineArn`, 127 | value: stateMachine.resource.stateMachineArn, 128 | }); 129 | 130 | return stateMachine; 131 | } 132 | 133 | // Creates a state machine scanning whole table in parallel and applying transform function to each item. 134 | public scan( 135 | transformFn: TransformFunctionType, 136 | options?: ScanInput 137 | ) { 138 | // By default, use factor of 10 for parallelism 139 | const totalSegments = options?.TotalSegments ?? 10; 140 | const segments = Array.from({ length: totalSegments }, (_, i) => i); 141 | 142 | // "this" cannot be referenced in a Function. 143 | const table = this.table; 144 | 145 | const transformFunction = new Function( 146 | this, 147 | "MigrationCallbackFunction", 148 | transformFn 149 | ); 150 | 151 | const stateMachine = new StepFunction( 152 | this, 153 | "MigrationStateMachine", 154 | { 155 | stateMachineName: this.migrationName, 156 | logs: { 157 | destination: new LogGroup(this, "MigrationLogGroup", { 158 | retention: RetentionDays.ONE_WEEK, 159 | }), 160 | level: LogLevel.ALL, 161 | }, 162 | }, 163 | async () => { 164 | return $SFN.map(segments, async (_, index) => { 165 | let lastEvaluatedKey; 166 | let firstRun = true; 167 | 168 | while (firstRun || lastEvaluatedKey) { 169 | firstRun = false; 170 | 171 | const result: ScanOutput = 172 | await $AWS.DynamoDB.Scan({ 173 | Table: table, 174 | TotalSegments: totalSegments, 175 | // Todo: figure out how to pass in options 176 | // FilterExpression: options?.FilterExpression, 177 | // AttributesToGet: options?.AttributesToGet, 178 | // ConsistentRead: options?.ConsistentRead, 179 | // ProjectionExpression: options?.ProjectionExpression, 180 | // IndexName: options?.IndexName, 181 | // ConditionalOperator: options?.ConditionalOperator, 182 | // Limit: options?.Limit, 183 | Segment: index, 184 | ExclusiveStartKey: lastEvaluatedKey, 185 | }); 186 | 187 | if (result.LastEvaluatedKey) { 188 | lastEvaluatedKey = result.LastEvaluatedKey; 189 | } 190 | 191 | await transformFunction({ result }); 192 | } 193 | }); 194 | } 195 | ); 196 | 197 | this.stateMachineArn = new CfnOutput(this, "StateMachineArn", { 198 | exportName: `${this.migrationName}StateMachineArn`, 199 | value: stateMachine.resource.stateMachineArn, 200 | }); 201 | 202 | return stateMachine; 203 | } 204 | } 205 | 206 | export type MigrationFunction = ( 207 | scope: Construct, 208 | id: string, 209 | props: MigrationProps 210 | ) => Migration; 211 | -------------------------------------------------------------------------------- /src/migrations-manager.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import { ExecutionStatus } from "@aws-sdk/client-sfn"; 4 | import { 5 | aws_dynamodb, 6 | CfnOutput, 7 | CustomResource, 8 | Duration, 9 | Fn, 10 | } from "aws-cdk-lib"; 11 | import { Provider } from "aws-cdk-lib/custom-resources"; 12 | import { Construct } from "constructs"; 13 | import { Table } from "functionless"; 14 | import CustomResourceIsMigrationCompleteChecker from "./custom-resource-is-migration-complete"; 15 | import CustomResourceMigrationsRunner from "./custom-resource-migrations-runner"; 16 | import { Migration } from "./migration"; 17 | 18 | export type MigrationManagerProps = { 19 | /** 20 | * Custom name for the DynamoDB table storing migrations 21 | */ 22 | tableName?: string; 23 | 24 | /** 25 | * Directory where migration files are stored 26 | */ 27 | migrationsDir: string; 28 | 29 | /** 30 | * Maximum time that can be allocated for all migrations applied during one deployment. 31 | * Cannot exceed 2 hours, it's CloudFormation's hard limitation. 32 | * 33 | * @default Duration.hours(2) 34 | */ 35 | totalMigrationsTimeout?: Duration; 36 | }; 37 | 38 | export type MigrationHistoryItem = { 39 | id: string; 40 | status: ExecutionStatus; 41 | startedAt: string; 42 | executionArn: string; 43 | endedAt?: string; 44 | }; 45 | 46 | export class MigrationsManager extends Construct { 47 | public readonly migrationsHistoryTable: Table; 48 | 49 | public readonly totalMigrationsTimeout: Duration; 50 | 51 | constructor(scope: Construct, id: string, props: MigrationManagerProps) { 52 | super(scope, id); 53 | 54 | this.totalMigrationsTimeout = 55 | props.totalMigrationsTimeout ?? Duration.hours(2); 56 | 57 | const migrationsHistoryTable = new Table( 58 | scope, 59 | "MigrationsHistoryTable", 60 | { 61 | tableName: props.tableName, 62 | partitionKey: { 63 | name: "id", 64 | type: aws_dynamodb.AttributeType.STRING, 65 | }, 66 | billingMode: aws_dynamodb.BillingMode.PAY_PER_REQUEST, 67 | } 68 | ); 69 | this.migrationsHistoryTable = migrationsHistoryTable; 70 | 71 | const migrationsDir = path.resolve(props.migrationsDir); 72 | const migrationFiles = fs.readdirSync(migrationsDir); 73 | let migrationStacks: Migration[] = []; 74 | 75 | for (const migrationFile of migrationFiles) { 76 | try { 77 | // Cannot use dynamic imports here due to synchronous nature of CDKs synthesis process 78 | // eslint-disable-next-line @typescript-eslint/no-require-imports 79 | const migrationStack = require(path.resolve( 80 | migrationsDir, 81 | migrationFile 82 | )).migration(this, migrationFile); 83 | 84 | migrationStacks.push(migrationStack); 85 | } catch (e) { 86 | console.log({ e }); 87 | throw new Error(`Error loading migration file ${migrationFile}: ${e}`); 88 | } 89 | } 90 | 91 | const migrationIdStateMachinePairs = migrationStacks.map((migration) => ({ 92 | stateMachineArn: Fn.importValue( 93 | `${migration.migrationName}StateMachineArn` 94 | ).toString(), 95 | migrationId: migration.migrationName, 96 | })); 97 | 98 | const onEventHandler = new CustomResourceMigrationsRunner( 99 | this, 100 | "MigrationsRunner", 101 | { 102 | migrationsHistoryTable, 103 | migrationIdStateMachinePairs, 104 | } 105 | ); 106 | 107 | const isCompleteHandler = new CustomResourceIsMigrationCompleteChecker( 108 | this, 109 | "MigrationsStatusChecker", 110 | { 111 | migrationsHistoryTable, 112 | migrationIdStateMachinePairs, 113 | } 114 | ); 115 | 116 | const migrationsProvider = new Provider(this, "MigrationsProvider", { 117 | totalTimeout: this.totalMigrationsTimeout, 118 | queryInterval: Duration.seconds(10), 119 | onEventHandler: onEventHandler.function.resource, 120 | isCompleteHandler: isCompleteHandler.function.resource, 121 | }); 122 | 123 | // Ensure migrations provider is ran after all nested stacks are created 124 | migrationStacks.map((stack) => { 125 | migrationsProvider.node.addDependency(stack); 126 | }); 127 | 128 | new CustomResource(this, "MigrationsTrigger", { 129 | serviceToken: migrationsProvider.serviceToken, 130 | properties: { 131 | // Force re-running the migrations every time the stack is updated 132 | timestamp: Date.now(), 133 | migrationIdStateMachinePairs, 134 | }, 135 | }); 136 | 137 | new CfnOutput(this, "MigrationsTableNameOutput", { 138 | value: migrationsHistoryTable.tableName, 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /tsconfig.dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "declaration": true, 5 | "esModuleInterop": true, 6 | "experimentalDecorators": true, 7 | "inlineSourceMap": true, 8 | "inlineSources": true, 9 | "lib": [ 10 | "es2019" 11 | ], 12 | "module": "CommonJS", 13 | "noEmitOnError": false, 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitAny": true, 16 | "noImplicitReturns": true, 17 | "noImplicitThis": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "resolveJsonModule": true, 21 | "strict": true, 22 | "strictNullChecks": true, 23 | "strictPropertyInitialization": true, 24 | "stripInternal": true, 25 | "target": "ES2019", 26 | "plugins": [ 27 | { 28 | "transform": "functionless/lib/compile" 29 | } 30 | ] 31 | }, 32 | "include": [ 33 | ".projenrc.js", 34 | "src/**/*.ts", 35 | "test/**/*.ts" 36 | ], 37 | "exclude": [ 38 | "node_modules" 39 | ], 40 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 41 | } 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "lib", 5 | "alwaysStrict": true, 6 | "declaration": true, 7 | "esModuleInterop": true, 8 | "experimentalDecorators": true, 9 | "inlineSourceMap": true, 10 | "inlineSources": true, 11 | "lib": [ 12 | "es2019" 13 | ], 14 | "module": "CommonJS", 15 | "noEmitOnError": false, 16 | "noFallthroughCasesInSwitch": true, 17 | "noImplicitAny": true, 18 | "noImplicitReturns": true, 19 | "noImplicitThis": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "resolveJsonModule": true, 23 | "strict": true, 24 | "strictNullChecks": true, 25 | "strictPropertyInitialization": true, 26 | "stripInternal": true, 27 | "target": "ES2019", 28 | "plugins": [ 29 | { 30 | "transform": "functionless/lib/compile" 31 | }, 32 | { 33 | "name": "@functionless/language-service" 34 | } 35 | ] 36 | }, 37 | "include": [ 38 | "src/**/*.ts" 39 | ], 40 | "exclude": [ 41 | "cdk.out" 42 | ], 43 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 44 | } 45 | --------------------------------------------------------------------------------