├── .DS_Store ├── .env.template ├── .envrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── user_story.md ├── pull_request_template.md ├── renovate.json └── workflows │ ├── build-distribution.yml │ ├── essential-test.yml │ ├── main.yml │ ├── publish.yml │ └── size.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg ├── common.sh ├── post-merge ├── pre-commit └── pre-push ├── .npmignore ├── .npmrc ├── .nvmrc ├── .size-limit.json ├── .tool-versions ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── NOTES.md ├── README.md ├── add-shebang.sh ├── app ├── .gitignore ├── index.ts ├── migrations │ └── .gitkeep ├── package.json └── tsconfig.json ├── biome.jsonc ├── cli.ts ├── commitlint.config.js ├── jest.config.ts ├── lib.ts ├── mangle.json ├── package-lock.json ├── package.json ├── src ├── cli │ ├── actions │ │ ├── index.ts │ │ └── newMigration.ts │ └── commander.ts ├── index-cli.ts ├── index-lib.ts └── lib │ ├── constants │ └── index.ts │ ├── domain │ ├── DatabaseService.spec.ts │ ├── DatabaseService.ts │ ├── MigrationService.spec.ts │ ├── MigrationService.ts │ ├── entities │ │ ├── Migration.spec.ts │ │ ├── Migration.ts │ │ └── index.ts │ ├── errors │ │ └── DuplicateMigrationError.ts │ ├── index.ts │ └── interfaces │ │ └── index.ts │ ├── migrationsCreateCollection.ts │ ├── migrationsCreateDatabase.ts │ ├── migrationsDownOne.ts │ ├── migrationsResetDatabase.ts │ ├── migrationsRunSequence.ts │ ├── repositories │ ├── LocalMigrationRepository.spec.ts │ ├── LocalMigrationRepository.ts │ ├── RemoteMigrationRepository.spec.ts │ ├── RemoteMigrationRepository.ts │ ├── entities │ │ ├── LocalMigrationEntity.spec.ts │ │ ├── LocalMigrationEntity.ts │ │ ├── MigrationFileEntity.spec.ts │ │ ├── MigrationFileEntity.ts │ │ ├── RemoteMigrationEntity.spec.ts │ │ ├── RemoteMigrationEntity.ts │ │ └── index.ts │ ├── index.ts │ └── interfaces │ │ └── index.ts │ ├── types │ └── index.ts │ └── utils │ ├── createId.spec.ts │ ├── createId.ts │ ├── exponentialBackoff.spec.ts │ ├── exponentialBackoff.ts │ ├── index.ts │ ├── poll.spec.ts │ ├── poll.ts │ ├── secondsToMilliseconds.spec.ts │ ├── secondsToMilliseconds.ts │ ├── sleep.spec.ts │ ├── sleep.ts │ └── type-guards │ ├── index.ts │ ├── isClass.spec.ts │ ├── isClass.ts │ ├── isRecord.spec.ts │ └── isRecord.ts ├── taze.config.js ├── test-utils ├── deferred.ts ├── index.ts └── mocks │ └── FakeMigration.ts ├── test ├── integration.spec.ts └── tsconfig.json ├── testpkg.sh └── tsconfig.json /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FranciscoKloganB/appwrite-database-migration-tool/bf2023c7732ed6f275934e8e2a1df8869a022f05/.DS_Store -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | # Required 2 | APPWRITE_FUNCTION_PROJECT_ID= 3 | # Required 4 | APPWRITE_API_KEY= 5 | # Required 6 | APPWRITE_ENDPOINT= 7 | # Defaults to 'Public' 8 | MIGRATIONS_DATABASE_ID= 9 | # Defaults to 'Migrations' 10 | MIGRATIONS_COLLECTION_ID= 11 | # Defaults to 'Migrations' 12 | MIGRATIONS_COLLECTION_NAME= 13 | # Defaults to './migrations' 14 | MIGRATIONS_HOME_FOLDER= 15 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | dotenv_if_exists .env.local 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | # Bug Report 2 | 3 | 4 | ## Context (Environment) 5 | 6 | 7 | 8 | ## Expected Behavior 9 | 10 | 11 | ## Current Behavior 12 | 13 | 14 | ## Steps to Reproduce or Reproduction Link 15 | 16 | 17 | 18 | 1. 19 | 2. 20 | 3. 21 | 4. 22 | 23 | ## Video, Screenshot or other visual aids 24 | 25 | 26 | ## Possible Solution 27 | 28 | 29 | ## Acceptance criteria 30 | 31 | **Scenario** the name for the behavior that will be describe 32 | 33 | **Given** the beginning state of the scenario 34 | 35 | **When** specific action that the user makes 36 | 37 | **Then** the outcome of the action in “When” 38 | 39 | **And** used to continue any of three previous statements 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/user_story.md: -------------------------------------------------------------------------------- 1 | # User Story 2 | 3 | ## Description 4 | 5 | **As a** ..., **I want to** ..., **so I can** ... 6 | 7 | _Ideally, this is in the issue title, but if not, you can put it here. If so, delete this section._ 8 | 9 | ## Acceptance criteria 10 | 11 | **Scenario** the name for the behavior that will be describe 12 | 13 | **Given** the beginning state of the scenario 14 | 15 | **When** specific action that the user makes 16 | 17 | **Then** the outcome of the action in “When” 18 | 19 | **And** used to continue any of three previous statements 20 | 21 | ## Technical Approach 22 | 23 | Some recommendations regarding project and implementation details 24 | 25 | ## Sprint Ready Checklist 26 | 27 | 1. - [ ] Acceptance criteria defined 28 | 2. - [ ] Team understands acceptance criteria 29 | 3. - [ ] Team has defined solution / steps to satisfy acceptance criteria 30 | 4. - [ ] Acceptance criteria is verifiable / testable 31 | 5. - [ ] External / 3rd Party dependencies identified 32 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Pull request 4 | 5 | ## Description 6 | 7 | 8 | 9 | ## Motivation and Context 10 | 11 | 12 | 13 | ## References 14 | 15 | 16 | 17 | 22 | 23 | ## Deployment Actions and Release Plan 24 | 25 | 26 | 27 | ### Pre-Deployment 28 | 29 | 32 | 33 | ### Post-Deployment 34 | 35 | 41 | 42 | ## Types of changes 43 | 44 | 45 | 46 | - [ ] Breaking change (a fix or feature that would cause existing functionality to change) 47 | - [ ] Bugfix (a non-breaking change that fixes an issue) 48 | - [ ] Chore (a task that doesn't add or fix existing code or tests) 49 | - [ ] New feature (a non-breaking change that adds functionality) 50 | - [ ] Performance (a non-breaking change that reduces time or space complexity of a flow) 51 | - [ ] Refactor (a non-breaking change that restructures existing code) 52 | - [ ] Test (added or updated existing tests without implementation changes) 53 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base"], 4 | "automerge": true, 5 | "ignoreDeps": [ 6 | "@types/node", 7 | "appwrite", 8 | "commander", 9 | "nanoid", 10 | "node", 11 | "node-appwrite", 12 | "tiny-invariant" 13 | ], 14 | "rangeStrategy": "bump", 15 | "updateInternalDeps": true 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/build-distribution.yml: -------------------------------------------------------------------------------- 1 | name: Callable Build Distribution Workflow 2 | 3 | on: [workflow_call] 4 | 5 | jobs: 6 | distribution: 7 | name: Build Distribution (${{ matrix.os }} - ${{ matrix.node }}) 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | node: ["22.0"] 12 | os: [ubuntu-latest] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | cache: npm 19 | node-version: ${{ matrix.node }} 20 | 21 | - name: Install Dependencies 22 | run: npm install 23 | 24 | - name: Create "dist/" Folder Contents 25 | run: npm run build 26 | 27 | - name: Create Package 28 | run: npm pack 29 | -------------------------------------------------------------------------------- /.github/workflows/essential-test.yml: -------------------------------------------------------------------------------- 1 | name: Callable Essential Test Workflow 2 | 3 | on: [workflow_call] 4 | 5 | jobs: 6 | test: 7 | name: Essential (${{ matrix.os }}, ${{ matrix.node }}) 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | node: ["22.0"] 12 | os: [ubuntu-latest] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | cache: npm 19 | node-version: ${{ matrix.node }} 20 | 21 | - name: Install Dependencies 22 | run: npm install 23 | 24 | - name: Scan Vulnerabilities 25 | run: npm audit --production 26 | 27 | - name: Lint and Format Check 28 | run: npm run biome:ci 29 | 30 | - name: Test Unit 31 | run: npm run test:ci:coverage 32 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | essential-test: 7 | name: Call Essential Test Workflow 8 | uses: ./.github/workflows/essential-test.yml 9 | 10 | build: 11 | name: Call Build Distribution Workflow 12 | needs: [essential-test] 13 | uses: ./.github/workflows/build-distribution.yml 14 | if: >- 15 | github.ref == 'refs/heads/main' || 16 | github.ref == 'refs/heads/master' 17 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | # Type 'published' triggers if the release is published, no matter it's draft or not. 5 | # Type 'created' triggers when a NON-draft release is created AND published. 6 | push: 7 | tags: 8 | - "v*.*.*" 9 | release: 10 | types: [created] 11 | 12 | jobs: 13 | essential-test: 14 | name: Call Essential Test Workflow 15 | uses: ./.github/workflows/essential-test.yml 16 | 17 | build-publishable-distribution: 18 | name: Publish 19 | needs: [essential-test] 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: actions/setup-node@v4 26 | with: 27 | cache: npm 28 | node-version: 22 29 | registry: https://registry.npmjs.org/ 30 | 31 | - name: Install dependencies 32 | run: npm install 33 | 34 | - name: Build Distribution 35 | run: npm run build 36 | 37 | - name: Publish 38 | run: npm publish 39 | env: 40 | NODE_AUTH_TOKEN: ${{secrets.NPM_AUTH_TOKEN}} 41 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: Check Bundle Size 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - master 8 | 9 | jobs: 10 | check-bundle-size: 11 | runs-on: ubuntu-latest 12 | env: 13 | CI_JOB_NUMBER: 1 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: andresz1/size-limit-action@v1 17 | with: 18 | github_token: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 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 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | temp 132 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | 2 | #!/bin/sh 3 | 4 | . "$(dirname "$0")/_/husky.sh" 5 | . "$(dirname "$0")/common.sh" 6 | 7 | npx commitlint --edit $1 --config commitlint.config.js 8 | -------------------------------------------------------------------------------- /.husky/common.sh: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FranciscoKloganB/appwrite-database-migration-tool/bf2023c7732ed6f275934e8e2a1df8869a022f05/.husky/common.sh -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm install 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | . "$(dirname "$0")/_/husky.sh" 4 | . "$(dirname "$0")/common.sh" 5 | 6 | npm run biome:precommit 7 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | . "$(dirname "$0")/_/husky.sh" 4 | . "$(dirname "$0")/common.sh" 5 | 6 | npm run typecheck 7 | npm run test:ci:coverage 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .commitlintrc.json 2 | .env 3 | .env.* 4 | .envrc 5 | .eslintignore 6 | .eslintrc.cjs 7 | .github/ 8 | .husky/ 9 | .prettierignore 10 | .prettierrc.cjs 11 | .size-limit.json 12 | **/*.cy.js 13 | **/*.cy.ts 14 | **/*.e2e.js 15 | **/*.e2e.ts 16 | **/*.spec.js 17 | **/*.spec.ts 18 | **/*.test.js 19 | **/*.test.ts 20 | app/ 21 | jest.config.ts 22 | mangle.json 23 | src/ 24 | test/ 25 | testpkg.sh 26 | tsconfig.json 27 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | ignore-scripts=false 3 | node_version=22.0.0 4 | shamefully-hoist=false 5 | # hint: to install peer dependencies automatically installed, add "auto-install-peers=true" 6 | auto-install-peers=false 7 | # hint: to skip peer dependency errors when pnpm to fails, add "strict-peer-dependencies=false" 8 | strict-peer-dependencies=false 9 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.0.0 2 | -------------------------------------------------------------------------------- /.size-limit.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "path": "dist/index.js", 4 | "limit": "10 KB" 5 | }, 6 | { 7 | "path": "dist/index.umd.js", 8 | "limit": "10 KB" 9 | }, 10 | { 11 | "path": "dist/index.modern.mjs", 12 | "limit": "10 KB" 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 22.0.0 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.autoClosingQuotes": "beforeWhitespace", 3 | "editor.codeActionsOnSave": { 4 | "source.organizeImports.biome": "explicit", 5 | "quickfix.biome": "explicit" 6 | }, 7 | "editor.defaultFormatter": "biomejs.biome", 8 | "editor.formatOnPaste": false, 9 | "editor.formatOnSave": true, 10 | "editor.insertSpaces": true, 11 | "editor.quickSuggestions": { 12 | "strings": true 13 | }, 14 | "editor.rulers": [88], 15 | "editor.tabSize": 2, 16 | "files.associations": { 17 | ".env.*": "properties" 18 | }, 19 | "files.eol": "\n", 20 | "files.insertFinalNewline": true, 21 | "yaml.schemas": { 22 | "https://json.schemastore.org/github-workflow.json": ".github/workflows/*" 23 | }, 24 | "[javascript]": { 25 | "editor.defaultFormatter": "biomejs.biome" 26 | }, 27 | "[typescript]": { 28 | "editor.defaultFormatter": "biomejs.biome" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [1.0.5](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v1.0.4...v1.0.5) (2024-04-27) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * **package.json:** relax node-appwrite peer dep to be >=9.0.0 ([8c94b41](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/8c94b4113a62c7304289913ecc0e526504d5d352)) 11 | 12 | ### [1.0.4](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v1.0.3...v1.0.4) (2024-04-27) 13 | 14 | ### [1.0.3](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v1.0.2...v1.0.3) (2024-04-27) 15 | 16 | ### [1.0.2](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v1.0.1...v1.0.2) (2024-04-27) 17 | 18 | ### [1.0.1](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v1.0.0...v1.0.1) (2024-01-17) 19 | 20 | ## [1.0.0](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.9.5...v1.0.0) (2024-01-17) 21 | 22 | 23 | ### Features 24 | 25 | * **MigrationService:** upsert migrations ([b5f5198](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/b5f5198f1ec7fad7762f4fa2359c54c94fff7763)) 26 | 27 | ### [0.9.5](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.9.4...v0.9.5) (2024-01-16) 28 | 29 | 30 | ### Bug Fixes 31 | 32 | * **MigrationService:** invokes updateMigration after unapply, must update test cases ([eafc38a](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/eafc38a9dec2568771489aeca4da8c9e5c5725cb)) 33 | * **MigrationService:** update tests ([d0f1e8a](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/d0f1e8aa5a295a4d431a67c4a02e7d0fc371cef4)) 34 | 35 | ### [0.9.4](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.9.3...v0.9.4) (2024-01-16) 36 | 37 | ### [0.9.3](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.9.2...v0.9.3) (2024-01-16) 38 | 39 | ### [0.9.2](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.9.1...v0.9.2) (2024-01-15) 40 | 41 | ### [0.9.1](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.9.0...v0.9.1) (2024-01-15) 42 | 43 | ## [0.9.0](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.8.3...v0.9.0) (2024-01-14) 44 | 45 | 46 | ### Features 47 | 48 | * add down one command ([69c06de](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/69c06deb1e8d337fe32865d710023eb4052305b6)) 49 | 50 | ### [0.8.3](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.8.2...v0.8.3) (2024-01-14) 51 | 52 | ### [0.8.2](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.8.1...v0.8.2) (2024-01-14) 53 | 54 | ### [0.8.1](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.8.0...v0.8.1) (2024-01-14) 55 | 56 | ## [0.8.0](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.7.2...v0.8.0) (2024-01-14) 57 | 58 | ### [0.7.2](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.7.1...v0.7.2) (2024-01-14) 59 | 60 | ### Features 61 | 62 | - add poll utility ([95f027b](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/95f027bf09ccfb19196d409c4a6e5561d1d1bbc3)) 63 | - **MigrationService:** add remoteMigrations and localMigrations getters ([7a74dc9](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/7a74dc971c0a0a2e141a8bf2f3100ee186f7a5d1)) 64 | 65 | ### [0.7.1](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.7.0...v0.7.1) (2024-01-12) 66 | 67 | ### Bug Fixes 68 | 69 | - **migrationsResetDatabase:** add module export and proper function name ([c66ead2](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/c66ead2b9cf950ab7c05e35e99677efb4de886c2)) 70 | 71 | ## [0.7.0](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.6.0...v0.7.0) (2024-01-11) 72 | 73 | ### Features 74 | 75 | - **DatabaseService:** add new utility methods ([b381676](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/b3816768eda2bb39084085cffba050e5799c399e)) 76 | - **migrationsResetDatabase:** add new utility migration which drops all collections ([77d6b1c](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/77d6b1caf44a1ff9b59d7472b066cae3b48f64d4)) 77 | 78 | ## [0.6.0](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.5.2...v0.6.0) (2024-01-09) 79 | 80 | ### Features 81 | 82 | - **logging:** forward error and info loggers to migration ([e24551d](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/e24551d21a06f8df3b71d5b512e560402c52a98a)) 83 | 84 | ### [0.5.2](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.5.1...v0.5.2) (2024-01-08) 85 | 86 | ### [0.5.1](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.5.0...v0.5.1) (2024-01-08) 87 | 88 | ### Bug Fixes 89 | 90 | - **Repositories:** eliminate circular dependency ([d173a6d](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/d173a6d575c849d58f8d0c510351caab5ca99834)) 91 | 92 | ## [0.5.0](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.4.5...v0.5.0) (2024-01-08) 93 | 94 | ### [0.4.5](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.4.4...v0.4.5) (2024-01-07) 95 | 96 | ### [0.4.4](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.4.3...v0.4.4) (2024-01-07) 97 | 98 | ### Bug Fixes 99 | 100 | - **MigrationService:** if a migration instance fails, throw error ([89d0cff](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/89d0cffbd6c8a56c43c5712857b9ae9ee54c4b46)) 101 | 102 | ### [0.4.3](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.4.2...v0.4.3) (2024-01-07) 103 | 104 | ### [0.4.2](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.4.1...v0.4.2) (2024-01-07) 105 | 106 | ### Bug Fixes 107 | 108 | - **Migration:** up was calling down, and down was calling up ([f4d0911](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/f4d0911d889bf5a51f0e41dce7f4b35a8125c2b3)) 109 | 110 | ### [0.4.1](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.4.0...v0.4.1) (2024-01-07) 111 | 112 | ## [0.4.0](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.3.0...v0.4.0) (2024-01-07) 113 | 114 | ### Features 115 | 116 | - **DatabaseService:** add utility methods to service instead of using utility functions ([166ca0a](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/166ca0a6f1929f376fb33b530e129276f33a34da)) 117 | 118 | ## [0.3.0](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.2.8...v0.3.0) (2024-01-06) 119 | 120 | ### Features 121 | 122 | - **domain:** create DatabaseSerive, exposing defined databaseId ([a19fc16](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/a19fc16b4b25b445df98e44e9fdf8a403e5e828d)) 123 | 124 | ### [0.2.8](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.2.7...v0.2.8) (2024-01-05) 125 | 126 | ### [0.2.7](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.2.6...v0.2.7) (2024-01-05) 127 | 128 | ### [0.2.6](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.2.5...v0.2.6) (2024-01-05) 129 | 130 | ### [0.2.5](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.2.4...v0.2.5) (2024-01-04) 131 | 132 | ### [0.2.4](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.2.3...v0.2.4) (2024-01-04) 133 | 134 | ### [0.2.3](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.2.1...v0.2.3) (2024-01-04) 135 | 136 | ### Bug Fixes 137 | 138 | - **createMigrationCollection:** add missing await keyword ([291aa0d](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/291aa0dbd12bede9ed6255be7fc65122c897fb51)) 139 | 140 | ### [0.2.2](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.2.1...v0.2.2) (2024-01-04) 141 | 142 | ### Bug Fixes 143 | 144 | - **createMigrationCollection:** add missing await keyword ([291aa0d](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/291aa0dbd12bede9ed6255be7fc65122c897fb51)) 145 | 146 | ### [0.2.1](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.2.0...v0.2.1) (2024-01-04) 147 | 148 | ### Bug Fixes 149 | 150 | - **createMigrationCollection:** add missing await keyword ([58fcaba](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/58fcabaa02a2e4603f667e8938f61bec0ecda8a9)) 151 | 152 | ## [0.2.0](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.1.4...v0.2.0) (2024-01-04) 153 | 154 | ### Bug Fixes 155 | 156 | - **createMigrationCollection:** no longer blows when migrations collection is not found ([68aa269](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/68aa2695020d95296efa9366bea7fff0de3ffd5e)) 157 | 158 | ### [0.1.4](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.1.3...v0.1.4) (2024-01-04) 159 | 160 | ### [0.1.3](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.1.2...v0.1.3) (2024-01-03) 161 | 162 | ### [0.1.2](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.1.1...v0.1.2) (2024-01-03) 163 | 164 | ### [0.1.1](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.1.0...v0.1.1) (2024-01-03) 165 | 166 | ## [0.1.0](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.0.5...v0.1.0) (2024-01-01) 167 | 168 | ### Features 169 | 170 | - **cli:** add admt binary for easier codegen ([1a7c1dd](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/1a7c1dd269a70ce037847ff6827944c03d425c41)) 171 | - **codegen:** migration scaffold outputs JS file to avoid asking for built migrations before deploy ([9764ea3](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/9764ea38cd90b0ec7cd356a9483ef156dc14857b)) 172 | 173 | ### [0.0.5](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.0.4...v0.0.5) (2023-12-31) 174 | 175 | ### Bug Fixes 176 | 177 | - **types:** add exports.types so that projects with moduleResolution 'Bundler' can find them ([2a72ccd](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/2a72ccddbbc2b07c7e67a629fb50c88d012f08ab)) 178 | 179 | ### [0.0.4](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.0.3...v0.0.4) (2023-12-31) 180 | 181 | ### [0.0.3](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.0.2...v0.0.3) (2023-12-31) 182 | 183 | ### Bug Fixes 184 | 185 | - **create-migration-collection:** collection attributes were not being created ([2ad028c](https://github.com/franciscokloganb/appwrite-database-migration-tool/commit/2ad028cd7e1a3e01b9e121ce406c28b5b64199d1)) 186 | 187 | ### [0.0.2](https://github.com/franciscokloganb/appwrite-database-migration-tool/compare/v0.0.1...v0.0.2) (2023-12-31) 188 | 189 | ### 0.0.1 (2023-12-31) 190 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Francisco Barros 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 | -------------------------------------------------------------------------------- /NOTES.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | ## Creating and publishing new releases with Standard version 4 | 5 | ```bash 6 | npm run release -- --first-release 7 | npm run release -- --release-as patch 8 | npm run release -- --release-as minor 9 | npm run release -- --release-as major 10 | ``` 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Appwrite Database Migration Tool 2 | 3 | An appwrite database migration tool with the goal of making schema and data changes across 4 | environments easier and more predictable. 5 | 6 | _**We strongly recommend reading through the entire README, paying close attention to 7 | [Setting-up](#setting-up) and [Recommendations](#recommendations) sections!**_ 8 | 9 | ## Setting Up 10 | 11 | ### Common/Shared Steps 12 | 13 | These steps only need to be made once*. 14 | 15 | - Create a `GitHub Repository` to host your Appwrite functions. 16 | - You can use a repository you already own (e.g., `myproject-functions`). 17 | - Create `environment` branches. 18 | - For example, the `main` branch can be assigned to `production` and `development` branch can be 19 | assigned to `staging`. This allows you to have multiple Appwrite projects, using a single functions 20 | repository containing multiple serverless entrypoints. Allowing you to effectively 21 | test a function in the staging project, before deploying the changes to the production project. 22 | 23 | ### Environment Specific Steps 24 | 25 | These steps need to be done per project that represents an application environment in which you want 26 | to use the Appwrite Database Migration Tool. 27 | 28 | #### Functions 29 | 30 | Appwrite serverless functions require access to the source code that they need to execute. The source 31 | is defined in Git repositories. Setups vary from team to team. Some choose to have one repository 32 | per serverless function (isolation), while others prefer a single repository with a single `package.json` 33 | for all functions (agility, low maintenance cost), others still, prefer a Monorepository structure 34 | with all functions in a single repository but each with its own `package.json` (agility, isolation, 35 | with higher upfront setup cost). No matter the strategy you must associate the source repository 36 | with Appwrite serverless functions. 37 | 38 | - [Appwrite Functions Docs](https://appwrite.io/docs/products/functions/deployment). 39 | - [Appwrite Functions Video Series (~50m)](https://www.youtube.com/watch?v=UAPt7VBL_T8). 40 | 41 | ##### MigrationsCreateCollection 42 | 43 | ###### Description 44 | 45 | > Creates a collection (defaults to `Migration`) which acts as the source of truth for migrations that have been applied or not. 46 | 47 | Create an Appwrite Function called `MigrationsCreateCollection` with the body below. The function 48 | should point at the branch that contains the source for the "environment". E.g.: Point at the 49 | `development` branch on the `staging` project and point to `main` in the `production` project. 50 | 51 | ```ts 52 | import { migrationsCreateCollection } from '@franciscokloganb/appwrite-database-migration-tool' 53 | 54 | export default async function(ctx) { 55 | await migrationsCreateCollection({ 56 | log: ctx.log, 57 | error: ctx.error, 58 | }) 59 | 60 | return ctx.res.empty(); 61 | } 62 | ``` 63 | 64 | ##### MigrationsRunSequence 65 | 66 | > Retrieves all migrations, picks the pending (unapplied) migration (if exists) and executes the up method. 67 | 68 | Create an Appwrite Function called `MigrationsRunSequence` with the body below. The function 69 | should point at the branch that contains the source for the "environment". 70 | 71 | - We recommend increase the maximum timeout for this function to 15m in the function settings. 72 | - Ensure the migration files created in the future are included in the final function bundle. 73 | - An example on what this means is given on [FAQ](#faq) section. 74 | 75 | ```ts 76 | import { migrationsRunSequence } from '@franciscokloganb/appwrite-database-migration-tool' 77 | 78 | export default async function(ctx) { 79 | await migrationsRunSequence({ 80 | log: ctx.log, 81 | error: ctx.error, 82 | }) 83 | 84 | return ctx.res.empty(); 85 | } 86 | ``` 87 | 88 | ##### MigrationsOneDown 89 | 90 | > Retrieves all migrations, picks the last applied migration (if exists) and executes the down method. 91 | 92 | Create an Appwrite Function called `MigrationsOneDown` with the body below. The function 93 | should point at the branch that contains the source for the "environment". 94 | 95 | - We recommend increase the maximum timeout for this function to 15m in the function settings. 96 | - Ensure the migration files created in the future are included in the final function bundle. 97 | - An example on what this means is given on [FAQ](#faq) section. 98 | 99 | ```ts 100 | import { migrationsRunSequence } from '@franciscokloganb/appwrite-database-migration-tool' 101 | 102 | export default async function(ctx) { 103 | await migrationsRunSequence({ 104 | log: ctx.log, 105 | error: ctx.error, 106 | }) 107 | 108 | return ctx.res.empty(); 109 | } 110 | ``` 111 | 112 | #### Functions (Optional) 113 | 114 | The functions below are optional. They exist for convinience. When used, they should be 115 | created following similar strategies to the one outlined in previous [Functions](#functions) section. 116 | 117 | ##### MigrationCreateDatabase 118 | 119 | Creates a Database with `name` and `id` matching the environment variable `MIGRATIONS_DATABASE_ID`, 120 | in the default case it will create a database called `Public`. If you already have a Database 121 | and you prefer to manage the Migrations collection in it, you do not need this function. 122 | 123 | ```ts 124 | import { migrationsCreateDatabase } from '@franciscokloganb/appwrite-database-migration-tool' 125 | 126 | export default async function(ctx) { 127 | await migrationsCreateDatabase({ 128 | log: ctx.log, 129 | error: ctx.error, 130 | }) 131 | 132 | return ctx.res.empty(); 133 | } 134 | ``` 135 | 136 | ##### MigrationResetDatabase 137 | 138 | Retrieves all collections which exist in the database associated with the environment variable 139 | `MIGRATIONS_DATABASE_ID` and then deletes them. The closest SQL analogy for this serverless function 140 | is `DROP TABLE IF EXISTS`. We recommend not setting this one up in your `production` project. 141 | 142 | ```ts 143 | import { migrationsResetDatabase } from '@franciscokloganb/appwrite-database-migration-tool' 144 | 145 | export default async function(ctx) { 146 | await migrationsResetDatabase({ 147 | log: ctx.log, 148 | error: ctx.error, 149 | }) 150 | 151 | return ctx.res.empty(); 152 | } 153 | ``` 154 | 155 | #### Function Environment Variables 156 | 157 | All functions that you just created require access to the environment variables below. You can set 158 | **them globally in your Appwrite project settings** or scope them to each function. If you opted 159 | for the scoped approach **ensure** the values match across functions. Also, **ensure** the config 160 | does not change over time if you run the `runMigrationSequence` at least once. The code is not 161 | adapted for configuration changes. While they are possible, we do not recommend doing them, unless 162 | you have a good reason and planned a transition. This includes updating environment variables, 163 | build paths, function names, or repository changes. Mistakes can leave your application in 164 | inconsistent states. 165 | 166 | ```properties 167 | # Required 168 | APPWRITE_FUNCTION_PROJECT_ID= 169 | # Required 170 | APPWRITE_API_KEY= 171 | # Required 172 | APPWRITE_ENDPOINT= 173 | # Defaults to 'Public' 174 | MIGRATIONS_DATABASE_ID= 175 | # Defaults to 'Migrations' 176 | MIGRATIONS_COLLECTION_ID= 177 | # Defaults to 'Migrations' 178 | MIGRATIONS_COLLECTION_NAME= 179 | # Defaults to './migrations' 180 | MIGRATIONS_HOME_FOLDER= 181 | ``` 182 | 183 | #### Finalize ADMT Setup 184 | 185 | - Execute `MigrationsCreateCollection` once and only once per environment/project. 186 | - We do prevent duplicate creations. 😇 187 | - Check that the `Migrations` collection was created with **at least** the following attributes 188 | (the `$id` attribute is not explicitly visible on the GUI): 189 | - `applied`: Boolean 190 | - `name`: String 191 | - `timestamp`: Integer 192 | 193 | #### Create Your First Migration 194 | 195 | - Use our codegen tool to create a new Migration JavaScript file. We give you type annotations 196 | through JSDocs (works just like TypeScript) without needing you to do transpilation steps. 197 | - The codegen tool is `Node` and `Bun` compatible. 198 | - Your description will be converted to `PascalCase`. 199 | - Whitespaces are not allowed. 200 | 201 | ```bash 202 | # E.g.: npx admt new-migration --outpath ./functions/database/migrations --descriptor CreateProductsCollection 203 | npx admt new-migration --outpath --descriptor 204 | ``` 205 | 206 | - Use the `databaseService` parameter of `up` and `down` to write your migration. 207 | - The `databaseService` is a subclass instance of `Databases` from `node-appwrite`. 208 | - The subclass provides some utility methods and properties on top of the normal `Databases`. 209 | - Once you are done, deploy push your changes through the environment pipelines. 210 | - E.g.: Push to `staging` execute the `MigrationsRunSequence` function on Appwrite UI. Verify all 211 | is good. Finally push to `production` and run the sequence there. 212 | 213 | ## Usage, Rules, Recommendations and, FAQ 214 | 215 | ### Rules 216 | 217 | - Migrations **must** complete within Appwrite Cloud defined timeout for the function (default is 15s). 218 | - Longer migrations should be run from local maching, by exporting variables in your `.env.local` for example. 219 | - **Never** change the file name of a migration file. 220 | - **Never** change the class name of a migration class. 221 | - **Always** use codegen tools to create new migration files or other supported operations. 222 | 223 | ### Recommendations 224 | 225 | #### Migrations in Appwrite 226 | 227 | Whether you are applying changes to your Appwrite database through their GUI (website), 228 | the Appwrite CLI, or using this package (ADMT), your changes are not guaranteed to be immediate. 229 | Your request for changes are "Eventually Consistent". For example, when you ask Appwrite to create a 230 | new attribute on a collection, that request goes to the queue. Eventually, a Worker picks up the 231 | request and commits your change requests to your database. Meaning that changes are not immediate 232 | and can (possibly?) occur out of order. 233 | 234 | **What are the implications for you as a developer?** 235 | 236 | Again, with an example. Assume you add an attribute `bar` to some existing collection. Shortly 237 | after. you try to create a document with data `{ "bar": "hello" }`. While the request may succeed, 238 | there is a chance you get an error informing that the format of the document is invalid and that 239 | `bar` does not exist on collection `Foo` when you executethe statement. This can happen with any 240 | other operation. Not just attributes and documents. Thus, to mitigate this issue, you should use the 241 | `poll` function exported by this package whenever you need to perform dependant and sequential 242 | operations in short time spans: 243 | 244 | ```js 245 | await db.createStringAttribute('[DATABASE_ID]', '[COLLECTION_ID]', 'bar', 32, true) 246 | 247 | // ❌ Bad code - Document creation may fail. Your request for attribute creation may still be queued. 248 | await dbService.createDocument( 249 | '[DATABASE_ID]', 250 | '[COLLECTION_ID]', 251 | '[DOCUMENT_ID]', 252 | { "bar": "hello" }, 253 | ) 254 | 255 | // ✅ Better code - Document creation unlikely to fail. You give time for Appwrite to work on your request (if needed). 256 | const [_, e] = await poll({ 257 | fetcher: () => 258 | db.getCollection('[DATABASE_ID]', '[COLLECTION_ID]'), 259 | isReady: ({ attributes }) => 260 | attributes.some(({ key, status }) => key === 'bar' && status === 'available'), 261 | }); 262 | 263 | if (e) { 264 | log(`Migration timed out. Unable to create '[DATABASE_ID]' documents.`) 265 | 266 | throw e 267 | } 268 | 269 | await dbService.createDocument( 270 | '[DATABASE_ID]', 271 | '[COLLECTION_ID]', 272 | '[DOCUMENT_ID]', 273 | { "bar": "hello" }, 274 | ) 275 | ``` 276 | 277 | The poll function runs the `fetcher` you provide up to six times applying an exponential backoff per 278 | try (0ms, 5000ms, 10000ms, 20000ms, 40000ms, 80000ms). Whenever the `fetcher` resolves, it calls the 279 | is `isReady` method you provided. In turn, if `isReady` returns `true`, the `poll` function 280 | resolves and returns the `fetcher` resolved data and a `null` error. **It is safe to call the next 281 | operations in your flow**. Otherwise, `poll` returns `null` data and an array of `error` explaining 282 | what went wrong. Particularly, the last `error` in the array will always be a generic `Error` 283 | indicating a "max retries reached" message. If there was at least one `fetcher` rejection, the 284 | original error(s) will be included on the errors array, in order of occurrence, before the generic 285 | error. Rejections from `fetcher` and `isReady` "failures" are ignored if at least one fetcher 286 | calls succeeds. The `poll` function never returns two non-null values at the same time! 287 | 288 | _Note: `poll` accepts optional `log`/`error` functions for verbose debugging in your Appwrite console._ 289 | 290 | #### Migrations in General 291 | 292 | - Avoid changing the contents of a migration that you have pushed to a production-like environment. 293 | - Unless you can confidently revert the database state (e.g.: staging) without affecting end-users. 294 | - Provide a meaningful descriptions for your migration using the `--descriptor` flag. 295 | - Keep them short, like `git commit` messages. 296 | - Follow the expand-and-contract pattern. 297 | - Read [here](https://www.prisma.io/dataguide/types/relational/expand-and-contract-pattern). 298 | - Follow the single-responsibility principle. 299 | - We do not have direct access to Appwrite's MariaDB instances, thus no real transaction 300 | mechanisms are used. 301 | - It's better to do incremental migrations then one migration that might leave your app in an 302 | inconsistent state. Plan your migrations! 303 | - Avoid abstractions in your migration files. They are subject to change in the future. If you use 304 | them, ensure that whatever happens, the output of the migration up sequence is always the same. A 305 | change of output in a migration M may cause a migration M + x, x > 0, to no longer work as intended. 306 | - Test your migration locally and your staging environment before releasing to production! 307 | - Mistakes happen. If you pushed to production and applying 'down' is not possible, we recommend 308 | creating a new migration file to patch the issue. 309 | - If that is not possible, restore your database to a previous point-in-time (backup). 310 | 311 | ### FAQ 312 | 313 | 1. How do I bundle my migrations in the final function bundle? 314 | a. Please note that currently Bun as an upstream issue with Axios, and fails to execute our 315 | functions due to missing `http` adapters. In the mean time I suggest you use plain `.js` files. 316 | 317 | a. You can track that issue here: , 318 | 319 | > How you bundle your migrations depends on your overall language choices and how you choose 320 | > to set up your Appwrite Function source repository structure. My personal setup using 321 | > a Bun based functions has the following Function Configurations. 322 | > Please note that appwrite does not allow you to do `newline` with continuation markers `\` like 323 | > I did in the example below example (for readability purposes). It expects the entire command 324 | > to be written in one line. 325 | > The `copy` command will only work if the folder already exists in your remote repository. 326 | 327 | ```code 328 | Entrypoint: ./dist/database/migrations-run-sequence.js 329 | Build Settings: \ 330 | bun install --production \ 331 | && bun build ./functions/database/migrations-run-sequence.ts --outdir ./dist/database \ 332 | && mkdir ./dist/database/migrations \ 333 | && cp -r ./functions/database/migrations ./dist/database/migrations ; 334 | ``` 335 | 336 | 2. My migrations are not being found when I execute `MigrationRunSequence`. 337 | 338 | > When Appwrite invokes a serverless function it automatically searches for your entrypoint 339 | > starting at `/usr/local/server/src/function/*`. Our code on the other hand, uses 340 | > `current working directory` to start searching for files. Appwrite serverless CWD is 341 | > `/usr/local/server/*`, meaning you need to modify your `MIGRATIONS_HOME_FOLDER` to consider the 342 | > `src` and `function` path segments if applicable. 343 | 344 | 3. I am getting scope errors when I execute the functions. 345 | 346 | > When we create an Appwrite Server Client (node-appwrite SDK), we use the APPWRITE_API_KEY you 347 | > provide to set it up. If you are getting scope errors, similar to these ones 348 | > "Error: (role: applications) missing scope 349 | > (collections.read)", it's because you need to add more scopes to your APPWRITE_API_KEY. 350 | > You can do that by accessing `Project > Settings > API credentials > View API Keys > { Select 351 | > API KEY } > Scopes`; From here onwards, you need to add the scopes that are missing. 352 | 353 | ## RFC 354 | 355 | ### Improved fault tolerance (pseudo-transactional behaviour) 356 | 357 | Currently, the `MigrationRunSequence` may fail while applying a user defined migration file. 358 | If the migration fails, the steps that were taken during that particular migration file, are not 359 | rolled back. Running the down method is an "OK" approach, but far from ideal, particularly on 360 | bigger migrations which involve modifying collection data rather than just creating/deleting fields. 361 | 362 | #### Possible Solution 363 | 364 | Implement a superset of commands around `DatabaseService` using a `Memento` pattern to try to mimic 365 | Transactions as much as possible. The solution involves `retries` with exponential backoff and would 366 | require some interesting API changes. This means that `ADMT` would no longer just expose Appwrite 367 | SDK `Databases` functionality, but would also have an API of commands that would intelligently 368 | know how to rollback themselves if all retries were expended and a given step failed. 369 | 370 | Something like the pseudo-code below: 371 | 372 | ```ts 373 | class SomeMigration { 374 | async up({ db, error, log, sequence }) { 375 | await sequence 376 | .addStep({ 377 | action: 'create', 378 | type: 'document', 379 | args: { 380 | ...argsToPassToDatabasesCreateDocumentMethod 381 | } 382 | }).addStep({ 383 | action: 'update', 384 | type: 'document', 385 | args: { 386 | ...argsToPassToDatabasesCreateDocumentMethod 387 | }, 388 | onError: async ({ db }) => { 389 | await db.executeCustomRollbackActionForThisStep() 390 | } 391 | }) 392 | 393 | // For each command that is successfully executed in the sequence queue 394 | // Push a record to an executed stack 395 | // If some command N + x, x > 0 fails 396 | // Pop from the executed stack and execute reverse of action 397 | // e.g.: delete document when action was create document 398 | await sequence.build().run() 399 | } 400 | } 401 | ``` 402 | -------------------------------------------------------------------------------- /add-shebang.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Adding the shebang avoids JS syntax error when running cli.cjs from CLI using package.json#bin field 4 | 5 | # Define the shebang line 6 | SHEBANG="#!/usr/bin/env node" 7 | 8 | # Add the shebang line to the specified file 9 | echo "$SHEBANG" | cat - ./dist/cli.cjs > temp && mv temp ./dist/cli.cjs 10 | 11 | echo "✅ Shebang line added to dist/cli.cjs" 12 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json 3 | Migration*.js 4 | migrations/**/* 5 | !migrations/.gitkeep 6 | -------------------------------------------------------------------------------- /app/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | // CommonJS require 3 | // const ADMT = require('@franciscokloganb/appwrite-database-migration-tool'); 4 | // console.log('CJS require import:', typeof ADMT.createMigrationCollection === 'function'); 5 | 6 | /** ModuleJS synthetic and/or default import */ 7 | // import SPKG from '@franciscokloganb/appwrite-database-migration-tool'; 8 | 9 | // console.log('MJS synthetic default:', typeof SPKG.createMigrationCollection === 'function'); 10 | 11 | /** ModuleJS aliased import */ 12 | 13 | /** ModuleJS Destruct MJS import */ 14 | import * as APKG from '@franciscokloganb/appwrite-database-migration-tool' 15 | import { createMigrationCollection } from '@franciscokloganb/appwrite-database-migration-tool' 16 | 17 | console.log( 18 | 'MJS named import as aliased object:', 19 | typeof APKG.createMigrationCollection === 'function', 20 | ) 21 | 22 | console.log( 23 | 'MJS named import destruct:', 24 | typeof createMigrationCollection === 'function', 25 | ) 26 | -------------------------------------------------------------------------------- /app/migrations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FranciscoKloganB/appwrite-database-migration-tool/bf2023c7732ed6f275934e8e2a1df8869a022f05/app/migrations/.gitkeep -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "type": "module", 4 | "scripts": { 5 | "test:admt": "admt new-migration --outpath ./migrations --descriptor foobar", 6 | "test:lib": "bun run index.ts" 7 | }, 8 | "dependencies": { 9 | "@franciscokloganb/appwrite-database-migration-tool": "file:../../../../local-npm-registry/franciscokloganb-appwrite-database-migration-tool-0.0.5.tgz" 10 | }, 11 | "devDependencies": { 12 | "ts-node": ">=10.9.2", 13 | "typescript": ">=5.4.5" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "lib": ["dom", "dom.iterable", "esnext"], 7 | "target": "es6", 8 | "allowJs": true, 9 | "baseUrl": ".", 10 | "checkJs": true, 11 | "incremental": true, 12 | "isolatedModules": true, 13 | "noEmit": true, 14 | "skipLibCheck": true, 15 | "strict": false 16 | }, 17 | "exclude": ["node_modules"] 18 | } 19 | -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.7.1/schema.json", 3 | "vcs": { 4 | "clientKind": "git", 5 | "defaultBranch": "main", 6 | "enabled": true, 7 | "useIgnoreFile": true 8 | }, 9 | "files": { 10 | "ignoreUnknown": true, 11 | "include": [ 12 | "src", 13 | "test", 14 | "test-utils", 15 | "*.js", 16 | "*.cjs", 17 | "*.mjs", 18 | "*.ts", 19 | "*.cts", 20 | "*.mts", 21 | "*.json", 22 | "*.md" 23 | ] 24 | }, 25 | "organizeImports": { 26 | "enabled": true 27 | }, 28 | "formatter": { 29 | "enabled": true, 30 | "formatWithErrors": false, 31 | "indentStyle": "space", 32 | "indentWidth": 2, 33 | "lineEnding": "lf", 34 | "lineWidth": 88, 35 | "attributePosition": "auto" 36 | }, 37 | "linter": { 38 | "enabled": true, 39 | "rules": { 40 | "recommended": true, 41 | "complexity": { 42 | "useLiteralKeys": "off" 43 | } 44 | } 45 | }, 46 | "javascript": { 47 | "formatter": { 48 | "arrowParentheses": "always", 49 | "attributePosition": "auto", 50 | "bracketSameLine": false, 51 | "bracketSpacing": true, 52 | "jsxQuoteStyle": "double", 53 | "quoteProperties": "preserve", 54 | "quoteStyle": "single", 55 | "semicolons": "asNeeded", 56 | "trailingComma": "all" 57 | } 58 | }, 59 | "overrides": [ 60 | { 61 | "include": ["*.spec.ts", "*.test.ts"], 62 | "linter": { 63 | "rules": { 64 | "suspicious": { 65 | "noExplicitAny": "off" 66 | } 67 | } 68 | } 69 | } 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /cli.ts: -------------------------------------------------------------------------------- 1 | export * from './src/index-cli' 2 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'type-enum': [ 5 | 2, 6 | 'always', 7 | [ 8 | 'build', 9 | 'chore', 10 | 'ci', 11 | 'docs', 12 | 'feat', 13 | 'fix', 14 | 'hotfix', 15 | 'perf', 16 | 'poc', 17 | 'refactor', 18 | 'revert', 19 | 'style', 20 | 'test', 21 | 'tidy', 22 | 'wip', 23 | ], 24 | ], 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { pathsToModuleNameMapper } from 'ts-jest' 2 | import type { JestConfigWithTsJest } from 'ts-jest' 3 | 4 | import { compilerOptions } from './tsconfig.json' 5 | 6 | const config: JestConfigWithTsJest = { 7 | verbose: true, 8 | moduleFileExtensions: ['js', 'json', 'ts'], 9 | rootDir: '.', 10 | testRegex: '.*\\.spec\\.ts$', 11 | transform: { 12 | '^.+\\.(t|j)s$': 'ts-jest', 13 | }, 14 | collectCoverageFrom: ['src/lib/**/*.(t|j)s'], 15 | coverageDirectory: '../coverage', 16 | coverageThreshold: { 17 | global: { 18 | functions: 80, 19 | lines: 60, 20 | statements: 60, 21 | branches: 50, 22 | }, 23 | }, 24 | modulePathIgnorePatterns: ['/app/', '/dist/'], 25 | moduleNameMapper: { 26 | ...pathsToModuleNameMapper(compilerOptions.paths, { 27 | prefix: '/', 28 | }), 29 | 'tiny-invariant': 30 | '/node_modules/tiny-invariant/src/tiny-invariant.flow.js', 31 | }, 32 | testEnvironment: 'node', 33 | testPathIgnorePatterns: ['/jest.config.ts', '/src/index.ts'], 34 | transformIgnorePatterns: ['/node_modules/(?!tiny-invariant)'], 35 | } 36 | 37 | export default config 38 | -------------------------------------------------------------------------------- /lib.ts: -------------------------------------------------------------------------------- 1 | export * from './src/index-lib' 2 | -------------------------------------------------------------------------------- /mangle.json: -------------------------------------------------------------------------------- 1 | { 2 | "mangle": { 3 | "regex": "^_" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@franciscokloganb/appwrite-database-migration-tool", 3 | "description": "An appwrite database migration tool with the goal of making schema and data changes across environments easier and more predictable.", 4 | "author": "franciscokloganb", 5 | "keywords": ["appwrite", "database", "migration", "tool"], 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git://github.com/franciscokloganb/appwrite-database-migration-tool.git" 10 | }, 11 | "publishConfig": { 12 | "access": "public" 13 | }, 14 | "exports": { 15 | ".": { 16 | "import": "./dist/lib.esm.js", 17 | "node": "./dist/lib.cjs", 18 | "require": "./dist/lib.cjs", 19 | "default": "./dist/lib.esm.js", 20 | "types": "./dist/lib.d.ts" 21 | }, 22 | "./lib": { 23 | "import": "./dist/lib.esm.js", 24 | "node": "./dist/lib.cjs", 25 | "require": "./dist/lib.cjs", 26 | "default": "./dist/lib.esm.js", 27 | "types": "./dist/lib.d.ts" 28 | }, 29 | "./cli": { 30 | "import": "./dist/cli.esm.js", 31 | "node": "./dist/cli.cjs", 32 | "require": "./dist/cli.cjs", 33 | "default": "./dist/cli.esm.js", 34 | "types": "./dist/cli.d.ts" 35 | } 36 | }, 37 | "bin": { 38 | "admt": "./dist/cli.cjs" 39 | }, 40 | "main": "./dist/lib.cjs", 41 | "module": "./dist/lib.esm.js", 42 | "unpkg": "./dist/lib.umd.js", 43 | "types": "./dist/lib.d.ts", 44 | "files": ["dist"], 45 | "devDependencies": { 46 | "@biomejs/biome": "^1.7.3", 47 | "@commitlint/cli": "^19.3.0", 48 | "@commitlint/config-conventional": "^19.2.2", 49 | "@faker-js/faker": "^8.4.1", 50 | "@golevelup/ts-jest": "^0.5.0", 51 | "@jest/types": "^29.6.3", 52 | "@size-limit/preset-small-lib": "^11.1.3", 53 | "@types/jest": "^29.5.12", 54 | "@types/jest-when": "^3.5.5", 55 | "@types/node": "^20.12.11", 56 | "change-case": "^5.4.4", 57 | "commit-and-tag-version": "^12.4.1", 58 | "fishery": "^2.2.2", 59 | "husky": "^9.0.11", 60 | "jest": "^29.7.0", 61 | "jest-when": "^3.6.0", 62 | "microbundle": "^0.15.1", 63 | "npm-cli-login": "^1.0.0", 64 | "npm-run-all2": "^6.1.2", 65 | "pinst": "^3.0.0", 66 | "rimraf": "^5.0.7", 67 | "size-limit": "^11.1.3", 68 | "start-server-and-test": "^2.0.3", 69 | "taze": "^0.13.8", 70 | "ts-jest": "^29.1.2", 71 | "ts-node": "^10.9.2", 72 | "tslib": "^2.6.2", 73 | "typescript": "^5.4.5" 74 | }, 75 | "peerDependencies": { 76 | "commander": "^12.0.0", 77 | "nanoid": "^3.3.4", 78 | "node-appwrite": ">=12.0.0", 79 | "tiny-invariant": "^1.3.1" 80 | }, 81 | "engines": { 82 | "node": ">=18.0.0" 83 | }, 84 | "scripts": { 85 | "add-shebang": "./add-shebang.sh", 86 | "auth:npm": "npm-cli-login -u $NPM_USER -p $NPM_PASS -e $NPM_EMAIL -r $NPM_REGISTRY -s $NPM_SCOPE", 87 | "biome:all": "biome check --apply-unsafe --formatter-enabled=true --linter-enabled=true --organize-imports-enabled=true --no-errors-on-unmatched .", 88 | "biome:precommit": "biome check --apply --changed --formatter-enabled=true --linter-enabled=true --organize-imports-enabled=true --no-errors-on-unmatched .", 89 | "biome:ci": "biome ci --no-errors-on-unmatched .", 90 | "postauth:npm": "cat ~/.npmrc", 91 | "prebuild": "rimraf dist", 92 | "build": "microbundle -i ./{cli,lib}.ts -f modern,esm,cjs,umd --target node", 93 | "postbuild": "npm run add-shebang", 94 | "dev": "microbundle watch", 95 | "prepare": "husky install && chmod ug+x .husky/*", 96 | "release": "commit-and-tag-version", 97 | "size": "size-limit", 98 | "test": "jest --watch", 99 | "test:ci": "jest --ci --maxWorkers=50%", 100 | "test:ci:coverage": "jest --ci --coverage --maxWorkers=50%", 101 | "typecheck": "tsc --project tsconfig.json", 102 | "prerelease": "git checkout main && git pull && run-s biome:precommit typecheck build", 103 | "postrelease": "git push --follow-tags --no-verify && npm publish", 104 | "prepack": "pinst --disable", 105 | "postpack": "pinst --enable", 106 | "up": "taze", 107 | "up:patch": "taze patch --install && npx taze -w && npm i", 108 | "up:minor": "taze minor --install && npx taze -w && npm i", 109 | "up:major": "taze major --install && npx taze -w && npm i" 110 | }, 111 | "type": "module", 112 | "version": "1.0.5" 113 | } 114 | -------------------------------------------------------------------------------- /src/cli/actions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './newMigration' 2 | -------------------------------------------------------------------------------- /src/cli/actions/newMigration.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | import { pascalCase } from 'change-case' 4 | 5 | export function newMigration({ 6 | outpath, 7 | descriptor, 8 | }: { outpath: string; descriptor: string }) { 9 | const description = pascalCase(descriptor) 10 | const timestamp = Date.now() 11 | 12 | const className = `Migration_${timestamp}_${description}` 13 | 14 | const fileName = `${className}.js` 15 | const filePath = path.resolve(process.cwd(), outpath, fileName) 16 | const fileContent = ` 17 | // @ts-check 18 | import { MigrationFileEntity } from '@franciscokloganb/appwrite-database-migration-tool'; 19 | 20 | class ${className} extends MigrationFileEntity { 21 | /** 22 | * @param {import('@franciscokloganb/appwrite-database-migration-tool').IMigrationCommandParams} params 23 | */ 24 | async up(params) { 25 | throw new Error('Method not implemented.'); 26 | } 27 | 28 | /** 29 | * @param {import('@franciscokloganb/appwrite-database-migration-tool').IMigrationCommandParams} params 30 | */ 31 | async down(params) { 32 | throw new Error('Method not implemented.'); 33 | } 34 | } 35 | 36 | export default ${className}; 37 | `.trim() 38 | 39 | // Create directories if they don't exist 40 | const fileDirectory = path.dirname(filePath) 41 | 42 | if (!fs.existsSync(fileDirectory)) { 43 | fs.mkdirSync(fileDirectory, { recursive: true }) 44 | } 45 | 46 | fs.writeFileSync(filePath, fileContent) 47 | 48 | console.log(`✅ Scaffold completed. Migration file created at: ${filePath}.`) 49 | } 50 | -------------------------------------------------------------------------------- /src/cli/commander.ts: -------------------------------------------------------------------------------- 1 | import { program } from 'commander' 2 | 3 | import { newMigration } from '@cli/actions' 4 | 5 | function validatePath(value: string) { 6 | if (!value.startsWith('./')) { 7 | console.error('Error: Invalid relative path. It should start with "./".') 8 | process.exit(1) 9 | } 10 | 11 | return value 12 | } 13 | 14 | function validateDescription(value: string) { 15 | if (/\s/.test(value)) { 16 | console.error('Error: Description should not contain spaces.') 17 | process.exit(1) 18 | } 19 | 20 | return value 21 | } 22 | 23 | program.version('1.0.0').description('Appwrite Database Migration Tool') 24 | 25 | program 26 | .command('new-migration') 27 | .description('Scaffold a new migration file') 28 | .option( 29 | '-d, --descriptor ', 30 | 'Short description for the migration file', 31 | validateDescription, 32 | ) 33 | .option( 34 | '-o, --outpath ', 35 | 'Relative path where the migration file will be created', 36 | validatePath, 37 | ) 38 | .action(({ outpath, descriptor }) => { 39 | newMigration({ outpath, descriptor }) 40 | }) 41 | 42 | program.parse(process.argv) 43 | -------------------------------------------------------------------------------- /src/index-cli.ts: -------------------------------------------------------------------------------- 1 | export * from './cli/commander' 2 | -------------------------------------------------------------------------------- /src/index-lib.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/constants' 2 | export * from './lib/domain' 3 | export * from './lib/migrationsCreateCollection' 4 | export * from './lib/migrationsCreateDatabase' 5 | export * from './lib/migrationsDownOne' 6 | export * from './lib/migrationsResetDatabase' 7 | export * from './lib/migrationsRunSequence' 8 | export * from './lib/repositories' 9 | export * from './lib/types' 10 | export * from './lib/utils' 11 | -------------------------------------------------------------------------------- /src/lib/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const MIGRATIONS_COLLECTION_ID = 'Migrations' 2 | export const MIGRATIONS_COLLECTION_NAME = 'Migrations' 3 | export const MIGRATIONS_DATABASE_ID = 'Public' 4 | export const MIGRATIONS_HOME_FOLDER = './migrations' 5 | -------------------------------------------------------------------------------- /src/lib/domain/DatabaseService.spec.ts: -------------------------------------------------------------------------------- 1 | import { createMock } from '@golevelup/ts-jest' 2 | import { AppwriteException, type Client, Databases } from 'node-appwrite' 3 | 4 | import { DatabaseService } from './DatabaseService' 5 | 6 | describe('DatabaseService', () => { 7 | const client = createMock() 8 | const databaseId = 'database-id' 9 | const databaseService = DatabaseService.create({ 10 | client, 11 | databaseId, 12 | }) 13 | 14 | beforeEach(() => { 15 | jest.resetAllMocks() 16 | }) 17 | 18 | describe('create', () => { 19 | it('should create an instance of DatabaseService with a valid `id` representing the database ID', () => { 20 | expect(databaseService).toBeInstanceOf(DatabaseService) 21 | expect(databaseService.id).toEqual(databaseId) 22 | }) 23 | 24 | it('should initialize Databases class with the provided client', () => { 25 | expect(databaseService).toBeInstanceOf(Databases) 26 | expect(databaseService.client).toEqual(client) 27 | }) 28 | }) 29 | 30 | describe('collectionExists', () => { 31 | const collectionId = 'bar' 32 | 33 | const getCollectionSpy = jest.spyOn(Databases.prototype, 'getCollection') 34 | 35 | beforeEach(() => { 36 | jest.resetAllMocks() 37 | }) 38 | 39 | it('should retrieve collection information from Appwrite', async () => { 40 | getCollectionSpy.mockResolvedValueOnce({} as any) 41 | 42 | databaseService.collectionExists(collectionId) 43 | 44 | expect(databaseService.getCollection).toHaveBeenCalledTimes(1) 45 | expect(databaseService.getCollection).toHaveBeenCalledWith( 46 | databaseId, 47 | collectionId, 48 | ) 49 | }) 50 | 51 | it('should return true if collection exists on Appwrite', async () => { 52 | getCollectionSpy.mockResolvedValueOnce({} as any) 53 | 54 | const result = await databaseService.collectionExists(collectionId) 55 | 56 | expect(result).toBe(true) 57 | }) 58 | 59 | it('should return false if collection does not exist on Appwrite', async () => { 60 | const error = new AppwriteException( 61 | 'Collection with the requested ID could not be found.', 62 | ) 63 | getCollectionSpy.mockRejectedValueOnce(error) 64 | 65 | const result = await databaseService.collectionExists(collectionId) 66 | 67 | expect(result).toBe(false) 68 | }) 69 | 70 | it('should return false if Appwrite yields other errors', async () => { 71 | const error = new AppwriteException('Something different happened.') 72 | getCollectionSpy.mockRejectedValueOnce(error) 73 | 74 | const result = await databaseService.collectionExists(collectionId) 75 | 76 | expect(result).toBe(false) 77 | }) 78 | 79 | it('should throw an error for other unexpected errors not related to Appwrite', async () => { 80 | const error = new Error('Unexpected error') 81 | getCollectionSpy.mockRejectedValueOnce(error) 82 | 83 | await expect( 84 | async () => await databaseService.collectionExists(collectionId), 85 | ).rejects.toThrow(error) 86 | }) 87 | }) 88 | 89 | describe('databaseExists', () => { 90 | const getDatabaseSpy = jest.spyOn(Databases.prototype, 'get') 91 | 92 | beforeEach(() => { 93 | jest.resetAllMocks() 94 | }) 95 | 96 | it('should retrieve database information from Appwrite', async () => { 97 | getDatabaseSpy.mockResolvedValueOnce({} as any) 98 | 99 | databaseService.databaseExists() 100 | 101 | expect(databaseService.get).toHaveBeenCalledTimes(1) 102 | expect(databaseService.get).toHaveBeenCalledWith(databaseId) 103 | }) 104 | 105 | it('should return true if database exists on Appwrite', async () => { 106 | getDatabaseSpy.mockResolvedValueOnce({} as any) 107 | 108 | const result = await databaseService.databaseExists() 109 | 110 | expect(result).toBe(true) 111 | }) 112 | 113 | it('should return false if database does not exist on Appwrite', async () => { 114 | const error = new AppwriteException('Database not found.') 115 | getDatabaseSpy.mockRejectedValueOnce(error) 116 | 117 | const result = await databaseService.databaseExists() 118 | 119 | expect(result).toBe(false) 120 | }) 121 | 122 | it('should return false if Appwrite yields other errors', async () => { 123 | const error = new AppwriteException('Something different happened.') 124 | getDatabaseSpy.mockRejectedValueOnce(error) 125 | 126 | const result = await databaseService.databaseExists() 127 | 128 | expect(result).toBe(false) 129 | }) 130 | 131 | it('should throw an error for other unexpected errors not related to Appwrite', async () => { 132 | const error = new Error('Unexpected error') 133 | getDatabaseSpy.mockRejectedValueOnce(error) 134 | 135 | await expect(async () => await databaseService.databaseExists()).rejects.toThrow( 136 | error, 137 | ) 138 | }) 139 | }) 140 | 141 | describe('dropCollection', () => { 142 | const collectionId = 'foo' 143 | const deleteCollectionSpy = jest.spyOn(Databases.prototype, 'deleteCollection') 144 | 145 | beforeEach(() => { 146 | jest.resetAllMocks() 147 | }) 148 | 149 | it('should call deleteCollection method with the correct parameters', async () => { 150 | await databaseService.dropCollection(collectionId) 151 | 152 | expect(deleteCollectionSpy).toHaveBeenCalledTimes(1) 153 | expect(deleteCollectionSpy).toHaveBeenCalledWith(databaseId, collectionId) 154 | }) 155 | }) 156 | 157 | describe('getCollections', () => { 158 | const listCollectionsSpy = jest.spyOn(Databases.prototype, 'listCollections') 159 | 160 | beforeEach(() => { 161 | jest.resetAllMocks() 162 | }) 163 | 164 | it('should call listCollections method with the correct parameters', async () => { 165 | await databaseService.getCollections() 166 | 167 | expect(listCollectionsSpy).toHaveBeenCalledTimes(1) 168 | expect(listCollectionsSpy).toHaveBeenCalledWith(databaseId) 169 | }) 170 | }) 171 | }) 172 | -------------------------------------------------------------------------------- /src/lib/domain/DatabaseService.ts: -------------------------------------------------------------------------------- 1 | import { AppwriteException, type Client, Databases } from 'node-appwrite' 2 | 3 | export class DatabaseService extends Databases { 4 | readonly #databaseId: string 5 | 6 | constructor(client: Client, databaseId: string) { 7 | super(client) 8 | 9 | this.#databaseId = databaseId 10 | } 11 | 12 | static create(props: { client: Client; databaseId: string }) { 13 | return new DatabaseService(props.client, props.databaseId) 14 | } 15 | 16 | get id() { 17 | return this.#databaseId 18 | } 19 | 20 | /* -------------------------------------------------------------------------- */ 21 | /* public methods */ 22 | /* -------------------------------------------------------------------------- */ 23 | public async collectionExists(collectionId: string) { 24 | try { 25 | await this.getCollection(this.#databaseId, collectionId) 26 | 27 | return true 28 | } catch (e) { 29 | if (e instanceof AppwriteException) { 30 | e.message.includes('Collection with the requested ID could not be found') 31 | 32 | return false 33 | } 34 | 35 | throw e 36 | } 37 | } 38 | 39 | public async databaseExists() { 40 | try { 41 | await this.get(this.#databaseId) 42 | 43 | return true 44 | } catch (e) { 45 | if (e instanceof AppwriteException) { 46 | e.message.includes('Database not found') 47 | 48 | return false 49 | } 50 | 51 | throw e 52 | } 53 | } 54 | 55 | public async dropCollection(collectionId: string) { 56 | return this.deleteCollection(this.id, collectionId) 57 | } 58 | 59 | public async getCollections() { 60 | return this.listCollections(this.id) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/lib/domain/MigrationService.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe } from 'node:test' 2 | import { createMock } from '@golevelup/ts-jest' 3 | 4 | import { 5 | type IMigrationFileEntity, 6 | LocalMigrationEntity, 7 | type LocalMigrationRepository, 8 | RemoteMigrationEntity, 9 | type RemoteMigrationRepository, 10 | } from '@lib/repositories' 11 | import { createId } from '@lib/utils' 12 | 13 | import type { DatabaseService } from './DatabaseService' 14 | import { MigrationService } from './MigrationService' 15 | import { Migration } from './entities/Migration' 16 | import { DuplicateMigrationError } from './errors/DuplicateMigrationError' 17 | 18 | describe('MigrationService', () => { 19 | const error = jest.fn() 20 | const log = jest.fn() 21 | 22 | const localMigrationRepository = createMock() 23 | const remoteMigrationRepository = createMock() 24 | 25 | const fts = 1705148650 26 | const fmn = `Migration_${fts}_AppliedRemote` 27 | const sts = 1705148849 28 | const smn = `Migration_${sts}_PendingRemote` 29 | const tts = 1805148555 30 | const tmn = `Migration_${tts}_PendingLocalOnly` 31 | 32 | function createDependencies() { 33 | const firstLocalEntity = LocalMigrationEntity.create({ 34 | instance: createMock(), 35 | name: fmn, 36 | timestamp: fts, 37 | }) 38 | 39 | const firstRemoteEntity = RemoteMigrationEntity.create({ 40 | id: createId(), 41 | applied: true, 42 | name: fmn, 43 | timestamp: fts, 44 | }) 45 | 46 | const secondLocalEntity = LocalMigrationEntity.create({ 47 | instance: createMock(), 48 | name: smn, 49 | timestamp: sts, 50 | }) 51 | 52 | const secondRemoteEntity = RemoteMigrationEntity.create({ 53 | id: createId(), 54 | applied: false, 55 | name: smn, 56 | timestamp: sts, 57 | }) 58 | 59 | const thirdLocalEntity = LocalMigrationEntity.create({ 60 | instance: createMock(), 61 | name: tmn, 62 | timestamp: tts, 63 | }) 64 | 65 | return { 66 | firstRemoteEntity, 67 | firstLocalEntity, 68 | secondLocalEntity, 69 | secondRemoteEntity, 70 | thirdLocalEntity, 71 | } 72 | } 73 | 74 | function createSubject() { 75 | return new MigrationService({ 76 | localMigrationRepository, 77 | remoteMigrationRepository, 78 | error, 79 | log, 80 | }) 81 | } 82 | 83 | beforeEach(() => { 84 | jest.clearAllMocks() 85 | }) 86 | 87 | describe('create', () => { 88 | it('should create an instance of MigrationService', () => { 89 | expect( 90 | MigrationService.create({ 91 | localMigrationRepository, 92 | remoteMigrationRepository, 93 | error: error, 94 | log: log, 95 | }), 96 | ).toBeInstanceOf(MigrationService) 97 | }) 98 | }) 99 | 100 | describe('local migration value objects', () => { 101 | it('should start as an empty array', () => { 102 | const migrationService = createSubject() 103 | 104 | expect(migrationService.localMigrations).toBeInstanceOf(Array) 105 | expect(migrationService.localMigrations).toHaveLength(0) 106 | }) 107 | 108 | describe('remoteMigrations', () => { 109 | it('should be an array', () => { 110 | const migrationService = createSubject() 111 | 112 | expect(migrationService.remoteMigrations).toBeInstanceOf(Array) 113 | }) 114 | 115 | it('should be empty', () => { 116 | const migrationService = createSubject() 117 | 118 | expect(migrationService.remoteMigrations).toHaveLength(0) 119 | }) 120 | }) 121 | 122 | describe('withLocalEntities', () => { 123 | it('should be possible to fill local migrations', async () => { 124 | const migrationService = createSubject() 125 | const { firstLocalEntity, secondLocalEntity } = createDependencies() 126 | 127 | localMigrationRepository.listMigrations.mockResolvedValue([ 128 | firstLocalEntity, 129 | secondLocalEntity, 130 | ]) 131 | 132 | await migrationService.withLocalEntities() 133 | 134 | expect(migrationService.localMigrations).toHaveLength(2) 135 | }) 136 | 137 | it('should sort the local migrations by timestamp ASC when they are loaded', async () => { 138 | const migrationService = createSubject() 139 | const { firstLocalEntity, secondLocalEntity } = createDependencies() 140 | 141 | localMigrationRepository.listMigrations.mockResolvedValue([ 142 | secondLocalEntity, 143 | firstLocalEntity, 144 | ]) 145 | 146 | await migrationService.withLocalEntities() 147 | 148 | const [olderVO, newerVO] = migrationService.localMigrations 149 | 150 | expect(olderVO.name).toEqual(firstLocalEntity.name) 151 | expect(olderVO.timestamp).toEqual(firstLocalEntity.timestamp) 152 | 153 | expect(newerVO.name).toEqual(secondLocalEntity.name) 154 | expect(newerVO.timestamp).toEqual(secondLocalEntity.timestamp) 155 | 156 | expect(newerVO.timestamp).toBeGreaterThan(olderVO.timestamp) 157 | }) 158 | 159 | it('should return the migration service instance allowing method chaining', async () => { 160 | const migrationService = createSubject() 161 | 162 | localMigrationRepository.listMigrations.mockResolvedValue([]) 163 | 164 | const result = await migrationService.withLocalEntities() 165 | 166 | expect(result).toBe(migrationService) 167 | }) 168 | 169 | it('should prevent duplicate local entities', async () => { 170 | const migrationService = createSubject() 171 | const { firstLocalEntity } = createDependencies() 172 | 173 | localMigrationRepository.listMigrations.mockResolvedValue([ 174 | firstLocalEntity, 175 | firstLocalEntity, 176 | ]) 177 | 178 | await expect(async () => migrationService.withLocalEntities()).rejects.toThrow( 179 | DuplicateMigrationError, 180 | ) 181 | }) 182 | }) 183 | 184 | describe('withRemoteEntities', () => { 185 | it('should be possible to fill remote migrations', async () => { 186 | const migrationService = createSubject() 187 | 188 | const { firstRemoteEntity, secondRemoteEntity } = createDependencies() 189 | 190 | remoteMigrationRepository.listMigrations.mockResolvedValue([ 191 | firstRemoteEntity, 192 | secondRemoteEntity, 193 | ]) 194 | 195 | await migrationService.withRemoteEntities() 196 | 197 | expect(migrationService.remoteMigrations).toHaveLength(2) 198 | }) 199 | 200 | it('should sort the remote migrations by timestamp ASC when they are loaded', async () => { 201 | const migrationService = createSubject() 202 | 203 | const { firstRemoteEntity, secondRemoteEntity } = createDependencies() 204 | 205 | remoteMigrationRepository.listMigrations.mockResolvedValue([ 206 | secondRemoteEntity, 207 | firstRemoteEntity, 208 | ]) 209 | 210 | await migrationService.withRemoteEntities() 211 | 212 | const [olderVO, newerVO] = migrationService.remoteMigrations 213 | 214 | expect(olderVO.name).toEqual(firstRemoteEntity.name) 215 | expect(olderVO.timestamp).toEqual(firstRemoteEntity.timestamp) 216 | 217 | expect(newerVO.name).toEqual(secondRemoteEntity.name) 218 | expect(newerVO.timestamp).toEqual(secondRemoteEntity.timestamp) 219 | 220 | expect(newerVO.timestamp).toBeGreaterThanOrEqual(olderVO.timestamp) 221 | }) 222 | 223 | it('should return the migration service instance allowing method chaining', async () => { 224 | const migrationService = createSubject() 225 | 226 | remoteMigrationRepository.listMigrations.mockResolvedValue([]) 227 | 228 | const result = await migrationService.withRemoteEntities() 229 | 230 | expect(result).toBe(migrationService) 231 | }) 232 | 233 | it('should prevent duplicate remote entities', async () => { 234 | const migrationService = createSubject() 235 | const { firstRemoteEntity } = createDependencies() 236 | 237 | remoteMigrationRepository.listMigrations.mockResolvedValue([ 238 | firstRemoteEntity, 239 | firstRemoteEntity, 240 | ]) 241 | 242 | await expect(async () => migrationService.withLocalEntities()).rejects.toThrow( 243 | DuplicateMigrationError, 244 | ) 245 | }) 246 | }) 247 | 248 | describe('migrations', () => { 249 | async function setup({ 250 | localMigrationEntities, 251 | remoteMigrationEntities, 252 | }: { 253 | localMigrationEntities?: LocalMigrationEntity[] 254 | remoteMigrationEntities?: RemoteMigrationEntity[] 255 | } = {}) { 256 | const migrationService = createSubject() 257 | 258 | const entities = createDependencies() 259 | 260 | remoteMigrationRepository.listMigrations.mockResolvedValue( 261 | remoteMigrationEntities ?? [ 262 | entities.firstRemoteEntity, 263 | entities.secondRemoteEntity, 264 | ], 265 | ) 266 | 267 | localMigrationRepository.listMigrations.mockResolvedValue( 268 | localMigrationEntities ?? [ 269 | entities.secondLocalEntity, 270 | entities.firstLocalEntity, 271 | entities.thirdLocalEntity, 272 | ], 273 | ) 274 | 275 | await migrationService.withLocalEntities() 276 | await migrationService.withRemoteEntities() 277 | await migrationService.withMigrations() 278 | 279 | return { ...entities, migrationService } 280 | } 281 | 282 | it('should mark migrations as being "persisted" according to whether they are found remotely during service setup', async () => { 283 | const { migrationService } = await setup() 284 | 285 | const [first, second, third] = migrationService.migrations 286 | 287 | expect(first.persisted).toBe(true) 288 | expect(second.persisted).toBe(true) 289 | expect(third.persisted).toBe(false) 290 | }) 291 | 292 | it('should return the migration service instance allowing method chaining', async () => { 293 | const migrationService = createSubject() 294 | 295 | await migrationService.withLocalEntities() 296 | await migrationService.withRemoteEntities() 297 | 298 | const result = await migrationService.withMigrations() 299 | 300 | expect(result).toBe(migrationService) 301 | }) 302 | 303 | it('should be possible to load migrations', async () => { 304 | const migrationService = createSubject() 305 | 306 | const { 307 | firstLocalEntity, 308 | firstRemoteEntity, 309 | secondLocalEntity, 310 | secondRemoteEntity, 311 | } = createDependencies() 312 | 313 | remoteMigrationRepository.listMigrations.mockResolvedValue([ 314 | firstRemoteEntity, 315 | secondRemoteEntity, 316 | ]) 317 | 318 | localMigrationRepository.listMigrations.mockResolvedValue([ 319 | firstLocalEntity, 320 | secondLocalEntity, 321 | ]) 322 | 323 | await migrationService.withLocalEntities() 324 | await migrationService.withRemoteEntities() 325 | await migrationService.withMigrations() 326 | 327 | const result = migrationService.migrations 328 | 329 | expect(result).toBeInstanceOf(Array) 330 | expect(result).toHaveLength(2) 331 | expect(result.every((m) => m instanceof Migration)).toBe(true) 332 | }) 333 | 334 | it('should sort the migrations by timestamp ASC when they are loaded', async () => { 335 | const { migrationService, firstLocalEntity, secondLocalEntity } = await setup() 336 | 337 | const [older, newer] = migrationService.migrations 338 | 339 | expect(older.name).toEqual(firstLocalEntity.name) 340 | expect(older.timestamp).toEqual(firstLocalEntity.timestamp) 341 | 342 | expect(newer.name).toEqual(secondLocalEntity.name) 343 | expect(newer.timestamp).toEqual(secondLocalEntity.timestamp) 344 | 345 | expect(newer.timestamp).toBeGreaterThan(older.timestamp) 346 | }) 347 | 348 | it('should be possible to retrieve the latest migration', async () => { 349 | const { migrationService, thirdLocalEntity } = await setup() 350 | 351 | expect(migrationService.latestMigration).toBeDefined() 352 | expect(migrationService.latestMigration?.name).toEqual(thirdLocalEntity.name) 353 | }) 354 | 355 | it('should undefined when retrieving the latest migration and migrations are not loaded', async () => { 356 | const migrationService = createSubject() 357 | 358 | expect(migrationService.latestMigration).toBeUndefined() 359 | }) 360 | 361 | it('should be possible to retrieve pending migrations', async () => { 362 | const { migrationService, secondRemoteEntity, thirdLocalEntity } = await setup() 363 | 364 | const result = migrationService.pendingMigrations 365 | 366 | expect(result).toBeInstanceOf(Array) 367 | expect(result).toHaveLength(2) 368 | expect(result[0].name).toEqual(secondRemoteEntity.name) 369 | expect(result[1].name).toEqual(thirdLocalEntity.name) 370 | }) 371 | 372 | it('should be possible to retrieve applied migrations', async () => { 373 | const { migrationService, firstRemoteEntity } = await setup() 374 | 375 | const result = migrationService.appliedMigrations 376 | 377 | expect(result).toBeInstanceOf(Array) 378 | expect(result).toHaveLength(1) 379 | expect(result[0].name).toEqual(firstRemoteEntity.name) 380 | }) 381 | 382 | describe('pending migration execution', () => { 383 | it('should apply all pending migrations', async () => { 384 | const { migrationService } = await setup() 385 | 386 | const databaseService = createMock() 387 | const migrationApplySpy = jest.spyOn(Migration.prototype, 'apply') 388 | 389 | expect(migrationService.appliedMigrations).toHaveLength(1) 390 | expect(migrationService.pendingMigrations).toHaveLength(2) 391 | 392 | await migrationService.executePendingMigrations(databaseService) 393 | 394 | expect(migrationService.appliedMigrations).toHaveLength(3) 395 | expect(migrationService.pendingMigrations).toHaveLength(0) 396 | 397 | expect(migrationApplySpy).toHaveBeenCalledTimes(2) 398 | }) 399 | 400 | it('should update the remote migration when it is applied when it it already exists remotely', async () => { 401 | const localEnt = LocalMigrationEntity.create({ 402 | instance: createMock(), 403 | name: smn, 404 | timestamp: sts, 405 | }) 406 | 407 | const remoteEnt = RemoteMigrationEntity.create({ 408 | id: createId(), 409 | applied: false, 410 | name: smn, 411 | timestamp: sts, 412 | }) 413 | 414 | const { migrationService } = await setup({ 415 | localMigrationEntities: [localEnt], 416 | remoteMigrationEntities: [remoteEnt], 417 | }) 418 | 419 | const databaseService = createMock() 420 | 421 | await migrationService.executePendingMigrations(databaseService) 422 | 423 | expect(remoteMigrationRepository.insertMigration).toHaveBeenCalledTimes(0) 424 | expect(remoteMigrationRepository.updateMigration).toHaveBeenCalledTimes(1) 425 | }) 426 | 427 | it('should write a remote migration when it is applied when it it already exists remotely', async () => { 428 | const entity = LocalMigrationEntity.create({ 429 | instance: createMock(), 430 | name: tmn, 431 | timestamp: tts, 432 | }) 433 | 434 | const databaseService = createMock() 435 | 436 | const { migrationService } = await setup({ 437 | localMigrationEntities: [entity], 438 | remoteMigrationEntities: [], 439 | }) 440 | 441 | await migrationService.executePendingMigrations(databaseService) 442 | 443 | expect(remoteMigrationRepository.insertMigration).toHaveBeenCalledTimes(1) 444 | expect(remoteMigrationRepository.updateMigration).toHaveBeenCalledTimes(0) 445 | }) 446 | 447 | it('should save applied migrations state to remote repository', async () => { 448 | const { migrationService } = await setup() 449 | 450 | const databaseService = createMock() 451 | 452 | await migrationService.executePendingMigrations(databaseService) 453 | 454 | // Migration 2 already exists on remote, so we updated it 455 | expect(remoteMigrationRepository.updateMigration).toHaveBeenCalledTimes(1) 456 | // Migration 3 is new (only exists locally), so we write it 457 | expect(remoteMigrationRepository.insertMigration).toHaveBeenCalledTimes(1) 458 | 459 | expect(migrationService.appliedMigrations.every((m) => m.persisted)).toBe( 460 | true, 461 | ) 462 | }) 463 | 464 | it('should throw an when a migration fails to apply', async () => { 465 | const error = new Error('failed to apply') 466 | const { migrationService } = await setup() 467 | 468 | const migrationApplySpy = jest.spyOn(Migration.prototype, 'apply') 469 | migrationApplySpy.mockRejectedValueOnce(error) 470 | 471 | const databaseService = createMock() 472 | 473 | await expect(async () => 474 | migrationService.executePendingMigrations(databaseService), 475 | ).rejects.toThrow(error) 476 | }) 477 | 478 | it('should throw an error when a migration is applied but state could not be persisted', async () => { 479 | const error = new Error('failed to persist') 480 | const { migrationService } = await setup() 481 | 482 | remoteMigrationRepository.insertMigration.mockRejectedValueOnce(error) 483 | 484 | const databaseService = createMock() 485 | 486 | await expect(async () => 487 | migrationService.executePendingMigrations(databaseService), 488 | ).rejects.toThrow(error) 489 | }) 490 | 491 | it('should not apply subsequent migration files when the current migration fails to apply', async () => { 492 | const error = new Error('failed to apply') 493 | const { migrationService } = await setup() 494 | 495 | const migrationApplySpy = jest.spyOn(Migration.prototype, 'apply') 496 | migrationApplySpy.mockRejectedValueOnce(error) 497 | 498 | const databaseService = createMock() 499 | 500 | try { 501 | await migrationService.executePendingMigrations(databaseService) 502 | } catch (e) { 503 | // pass 504 | } 505 | 506 | expect(migrationApplySpy).toHaveBeenCalledTimes(1) 507 | }) 508 | 509 | it('should not apply subsequent migration files when the current migration fails to persist', async () => { 510 | const error = new Error('failed to persist') 511 | const { migrationService } = await setup() 512 | 513 | const migrationApplySpy = jest.spyOn(Migration.prototype, 'apply') 514 | 515 | remoteMigrationRepository.insertMigration.mockRejectedValueOnce(error) 516 | remoteMigrationRepository.updateMigration.mockRejectedValueOnce(error) 517 | 518 | const databaseService = createMock() 519 | 520 | try { 521 | await migrationService.executePendingMigrations(databaseService) 522 | } catch (e) { 523 | // pass 524 | } 525 | 526 | expect(migrationApplySpy).toHaveBeenCalledTimes(1) 527 | }) 528 | }) 529 | 530 | describe('undoing last migration', () => { 531 | it('it should only undo one migration', async () => { 532 | const firstLocalEntity = LocalMigrationEntity.create({ 533 | instance: createMock(), 534 | name: fmn, 535 | timestamp: fts, 536 | }) 537 | 538 | const firstRemoteEntity = RemoteMigrationEntity.create({ 539 | id: createId(), 540 | applied: true, 541 | name: fmn, 542 | timestamp: fts, 543 | }) 544 | 545 | const secondLocalEntity = LocalMigrationEntity.create({ 546 | instance: createMock(), 547 | name: smn, 548 | timestamp: sts, 549 | }) 550 | 551 | const secondRemoteEntity = RemoteMigrationEntity.create({ 552 | id: createId(), 553 | applied: true, 554 | name: smn, 555 | timestamp: sts, 556 | }) 557 | 558 | const { migrationService } = await setup({ 559 | localMigrationEntities: [firstLocalEntity, secondLocalEntity], 560 | remoteMigrationEntities: [firstRemoteEntity, secondRemoteEntity], 561 | }) 562 | 563 | const databaseService = createMock() 564 | const migrationUnapplySpy = jest.spyOn(Migration.prototype, 'unapply') 565 | 566 | expect(migrationService.appliedMigrations).toHaveLength(2) 567 | expect(migrationService.pendingMigrations).toHaveLength(0) 568 | 569 | await migrationService.undoLastMigration(databaseService) 570 | 571 | expect(migrationService.pendingMigrations).toHaveLength(1) 572 | expect(migrationService.appliedMigrations).toHaveLength(1) 573 | expect(migrationUnapplySpy).toHaveBeenCalledTimes(1) 574 | }) 575 | 576 | it('should throw an when a migration fails to undo', async () => { 577 | const error = new Error('failed to unapply') 578 | const { migrationService } = await setup() 579 | 580 | const migrationUnapplySpy = jest.spyOn(Migration.prototype, 'unapply') 581 | migrationUnapplySpy.mockRejectedValueOnce(error) 582 | 583 | const databaseService = createMock() 584 | 585 | await expect(async () => 586 | migrationService.undoLastMigration(databaseService), 587 | ).rejects.toThrow(error) 588 | }) 589 | 590 | it('should throw an error when a migration is unapplied but state could not be updated', async () => { 591 | const error = new Error('failed to persist') 592 | const { migrationService } = await setup() 593 | 594 | remoteMigrationRepository.updateMigration.mockRejectedValueOnce(error) 595 | 596 | const databaseService = createMock() 597 | 598 | await expect(async () => 599 | migrationService.undoLastMigration(databaseService), 600 | ).rejects.toThrow(error) 601 | }) 602 | }) 603 | }) 604 | }) 605 | }) 606 | -------------------------------------------------------------------------------- /src/lib/domain/MigrationService.ts: -------------------------------------------------------------------------------- 1 | import type { IMigrationEntity } from '@lib/repositories/interfaces' 2 | import type { Logger, TransactionMode } from '@lib/types' 3 | import { createId } from '@lib/utils' 4 | 5 | import type { DatabaseService } from '.' 6 | import type { 7 | LocalMigrationEntity, 8 | LocalMigrationRepository, 9 | RemoteMigrationEntity, 10 | RemoteMigrationRepository, 11 | } from '../../index-lib' 12 | import { Migration } from './entities' 13 | import { DuplicateMigrationError } from './errors/DuplicateMigrationError' 14 | import type { LocalMigrationVO, RemoteMigrationVO } from './interfaces' 15 | 16 | type MigrationServiceProps = { 17 | localMigrationRepository: LocalMigrationRepository 18 | remoteMigrationRepository: RemoteMigrationRepository 19 | error: Logger 20 | log: Logger 21 | } 22 | 23 | export class MigrationService { 24 | /** Indicates the transaction mode in which a sequence of pending transactions is executed */ 25 | static readonly TRANSACTION_MODE: TransactionMode = 'each' 26 | 27 | /** Service that can be used to interact with Filesystem migration collection */ 28 | readonly #localMigrationRepository: LocalMigrationRepository 29 | /** Service that can be used to interact with Appwrite migration collection */ 30 | readonly #remoteMigrationRepository: RemoteMigrationRepository 31 | 32 | /** A function that can be used to log error messages */ 33 | readonly #error: Logger 34 | /** A function that can be used to log information messages */ 35 | readonly #log: Logger 36 | 37 | #localEntities: LocalMigrationEntity[] = [] 38 | #remoteEntities: RemoteMigrationEntity[] = [] 39 | 40 | #migrations: Migration[] = [] 41 | 42 | /* -------------------------------------------------------------------------- */ 43 | /* constructor */ 44 | /* -------------------------------------------------------------------------- */ 45 | 46 | public constructor(props: MigrationServiceProps) { 47 | this.#localMigrationRepository = props.localMigrationRepository 48 | this.#remoteMigrationRepository = props.remoteMigrationRepository 49 | this.#error = props.error 50 | this.#log = props.log 51 | } 52 | 53 | public static create(props: MigrationServiceProps) { 54 | return new MigrationService(props) 55 | } 56 | 57 | /* -------------------------------------------------------------------------- */ 58 | /* builder */ 59 | /* -------------------------------------------------------------------------- */ 60 | 61 | /** 62 | * Loads all migration document entities from Appwrite and sorts them by Timestamp ASC. 63 | */ 64 | public async withRemoteEntities() { 65 | this.#log('Will retrieve migration data from Appwrite.') 66 | 67 | const entities = await this.#remoteMigrationRepository.listMigrations() 68 | 69 | this.#log( 70 | `Migration data retrieved from Appwrite. Found ${entities.length} entries.`, 71 | ) 72 | 73 | this.assertNoDuplicateMigrations(entities) 74 | 75 | this.sortEntities(entities) 76 | this.#remoteEntities = entities 77 | 78 | return this 79 | } 80 | 81 | /** 82 | * Loads all migration document entities from Filesystem and sorts them by Timestamp ASC. 83 | */ 84 | public async withLocalEntities() { 85 | this.#log('Will retrieve migration data from Filesystem.') 86 | 87 | const entities = await this.#localMigrationRepository.listMigrations() 88 | 89 | this.#log( 90 | `Migration data retrieved from Filesystem. Found ${entities.length} entries.`, 91 | ) 92 | 93 | this.assertNoDuplicateMigrations(entities) 94 | 95 | this.sortEntities(entities) 96 | this.#localEntities = entities 97 | 98 | return this 99 | } 100 | 101 | /** 102 | * Builds migration domain entities by combining remote and local entities. 103 | * 104 | * It is assumed `withRemoteEntities` and `withLocalEntities` builders were already invoked. 105 | */ 106 | public withMigrations() { 107 | this.#migrations = this.#localEntities.map((local) => { 108 | const remote = this.#remoteEntities.find((rmt) => rmt.name === local.name) 109 | 110 | return Migration.create({ 111 | applied: remote?.applied || local.applied, 112 | id: remote?.$id || createId(), 113 | instance: local.instance, 114 | name: local.name, 115 | persisted: !!remote, 116 | timestamp: local.timestamp, 117 | }) 118 | }) 119 | 120 | return this 121 | } 122 | 123 | /* -------------------------------------------------------------------------- */ 124 | /* public methods */ 125 | /* -------------------------------------------------------------------------- */ 126 | /** Gets an array containing all executed migrations, sorted by timestamp ASC. */ 127 | public get appliedMigrations(): Array { 128 | return this.#migrations.filter((m) => m.isExecuted()) 129 | } 130 | 131 | /** Gets an array containing local migration value objects sorted by timestamp ASC */ 132 | public get localMigrations(): Array { 133 | return this.#localEntities.map((m) => m.value) 134 | } 135 | 136 | /** Gets an array containing remote migration value objects sorted by timestamp ASC */ 137 | public get remoteMigrations(): Array { 138 | return this.#remoteEntities.map((m) => m.value) 139 | } 140 | 141 | /** Gets an array containing all executed and pending migrations sorted by timestamp ASC. */ 142 | public get migrations(): Array { 143 | return this.#migrations 144 | } 145 | 146 | /** Gets an array containing all pending migrations, sorted by timestamp ASC. */ 147 | public get pendingMigrations(): Array { 148 | return this.#migrations.filter((m) => m.isPending()) 149 | } 150 | 151 | /** Gets the latest migration regardless of it's applied state */ 152 | public get latestMigration(): Migration | undefined { 153 | return this.#migrations.at(-1) 154 | } 155 | 156 | /** 157 | * Executes all pending migrations. 158 | * 159 | * Pending migrationss are the ones in our local system but not stored as a document on Appwrite collection 160 | */ 161 | public async executePendingMigrations(databaseService: DatabaseService) { 162 | this.#log(`There are ${this.pendingMigrations.length} pending migrations.`) 163 | 164 | for await (const migration of this.pendingMigrations) { 165 | try { 166 | this.#log(`Pending migration ${migration.name} is being applied.`) 167 | 168 | await migration.apply({ 169 | db: databaseService, 170 | log: this.#log, 171 | error: this.#error, 172 | }) 173 | 174 | this.#log(`Pending migration ${migration.name} was applied.`) 175 | } catch (error) { 176 | this.#error(`Error applying pending migration ${migration.name}. Aborting...`) 177 | 178 | throw error 179 | } 180 | 181 | try { 182 | this.#log(`Migration ${migration.name} new state will be saved to Appwrite.`) 183 | 184 | migration.persisted 185 | ? await this.#remoteMigrationRepository.updateMigration({ 186 | $id: migration.$id, 187 | applied: true, 188 | }) 189 | : await this.#remoteMigrationRepository.insertMigration({ 190 | $id: migration.$id, 191 | applied: migration.applied, 192 | timestamp: migration.timestamp, 193 | name: migration.name, 194 | }) 195 | 196 | migration.setPersisted(true) 197 | 198 | this.#log(`Migration ${migration.name} new state was saved. Completed.`) 199 | } catch (insertError) { 200 | this.#error( 201 | `Migration ${migration.name} was applied but new state was not saved.`, 202 | ) 203 | 204 | throw insertError 205 | } 206 | } 207 | 208 | this.#log('All Pending migrations were applied.') 209 | } 210 | 211 | /** Reverts last migration that were executed */ 212 | public async undoLastMigration(databaseService: DatabaseService) { 213 | this.#log('Undoing last applied migration...') 214 | 215 | const migration = this.appliedMigrations.at(-1) 216 | 217 | if (migration) { 218 | try { 219 | await migration.unapply({ 220 | db: databaseService, 221 | log: this.#log, 222 | error: this.#error, 223 | }) 224 | } catch (e) { 225 | this.#error(`Failed to undo last applied migration: ${migration.name}.`) 226 | 227 | throw e 228 | } 229 | 230 | try { 231 | this.#log(`Migration ${migration.name} new state will be updated on Appwrite.`) 232 | 233 | await this.#remoteMigrationRepository.updateMigration({ 234 | $id: migration.$id, 235 | applied: false, 236 | }) 237 | 238 | this.#log(`Migration ${migration.name} new state was updated. Completed.`) 239 | } catch (insertError) { 240 | this.#error( 241 | `Migration ${migration.name} was unapplied but new state was not updated.`, 242 | ) 243 | 244 | throw insertError 245 | } 246 | 247 | this.#log( 248 | `Migration 'down' method completed. The ${migration.name} is no longer applied.`, 249 | ) 250 | 251 | return 252 | } 253 | 254 | this.#log('No applied migrations found. Skipped.') 255 | } 256 | 257 | /* -------------------------------------------------------------------------- */ 258 | /* protected methods */ 259 | /* -------------------------------------------------------------------------- */ 260 | 261 | /** TODO Runs application routines after the execution of all migrations (before exitting) */ 262 | protected async afterAll(): Promise { 263 | throw new Error('Method not implemented.') 264 | } 265 | 266 | /** TODO Runs applicational routines after migration completes successfully */ 267 | protected async afterOneCompleted(_: IMigrationEntity): Promise { 268 | throw new Error('Method not implemented.') 269 | } 270 | 271 | /** TODO Runs applicational routines and cleanup after migration fails to complete */ 272 | protected async afterOneFailed(_: IMigrationEntity): Promise { 273 | throw new Error('Method not implemented.') 274 | } 275 | 276 | /** TODO Runs applicational routines and sets up the executor before executing migrations */ 277 | protected async beforeAll(): Promise { 278 | throw new Error('Method not implemented.') 279 | } 280 | 281 | /* -------------------------------------------------------------------------- */ 282 | /* private methods */ 283 | /* -------------------------------------------------------------------------- */ 284 | 285 | /** Validates there are no duplicates in the provided migration document entities by checking names */ 286 | private assertNoDuplicateMigrations(migrations: IMigrationEntity[]) { 287 | const names = migrations.map((migration) => migration.name) 288 | const uniqueNames = Array.from(new Set([...names])) 289 | 290 | if (uniqueNames.length === names.length) { 291 | return true 292 | } 293 | 294 | throw new DuplicateMigrationError() 295 | } 296 | 297 | /** Performs an inplace sort of the provided migration document entities by timestamp ASC */ 298 | private sortEntities(migrations: IMigrationEntity[]) { 299 | return migrations.sort((a, b) => a.timestamp - b.timestamp) 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /src/lib/domain/entities/Migration.spec.ts: -------------------------------------------------------------------------------- 1 | import { createMock } from '@golevelup/ts-jest' 2 | 3 | import type { DatabaseService } from '@lib/domain' 4 | import type { IMigrationFileEntity } from '@lib/repositories' 5 | import type { Logger } from '@lib/types' 6 | import { createId } from '@lib/utils' 7 | 8 | import { Migration, type MigrationProps } from './Migration' 9 | 10 | describe('Migration', () => { 11 | const mockDbService = createMock() 12 | const mockLog = createMock() 13 | const mockError = createMock() 14 | const mockInstance = createMock() 15 | 16 | const baseProps: MigrationProps = { 17 | applied: false, 18 | id: createId(), 19 | instance: mockInstance, 20 | name: 'MockMigration', 21 | persisted: false, 22 | timestamp: Date.now(), 23 | } 24 | 25 | beforeEach(() => { 26 | jest.resetAllMocks() 27 | jest.useFakeTimers() 28 | }) 29 | 30 | afterEach(() => { 31 | jest.useRealTimers() 32 | }) 33 | 34 | describe('create', () => { 35 | it('should create an instance using the static create method', () => { 36 | const migration = Migration.create(baseProps) 37 | 38 | expect(migration).toBeInstanceOf(Migration) 39 | expect(migration.$id).toEqual(baseProps.id) 40 | expect(migration.applied).toEqual(baseProps.applied) 41 | expect(migration.name).toEqual(baseProps.name) 42 | expect(migration.timestamp).toEqual(baseProps.timestamp) 43 | expect(migration.instance).toBe(baseProps.instance) 44 | }) 45 | }) 46 | 47 | describe('value', () => { 48 | it('should return the a value object when accessing the value property', () => { 49 | const migration = Migration.create({ ...baseProps }) 50 | const value = migration.value 51 | 52 | expect(value).toMatchObject({ 53 | $id: baseProps.id, 54 | applied: baseProps.applied, 55 | name: baseProps.name, 56 | timestamp: baseProps.timestamp, 57 | }) 58 | }) 59 | }) 60 | 61 | describe('persisted', () => { 62 | it('should return false for when persisted is false', () => { 63 | const migration = Migration.create({ ...baseProps, persisted: false }) 64 | expect(migration.persisted).toBe(false) 65 | }) 66 | 67 | it('should return true for when persisted is true', () => { 68 | const migration = Migration.create({ ...baseProps, persisted: true }) 69 | expect(migration.persisted).toBe(true) 70 | }) 71 | 72 | describe('setPersisted', () => { 73 | it('should be possible to change the value of the persisted property', () => { 74 | const migration = Migration.create({ ...baseProps }) 75 | expect(migration.persisted).toBe(false) 76 | 77 | migration.setPersisted(true) 78 | expect(migration.persisted).toBe(true) 79 | 80 | migration.setPersisted(false) 81 | expect(migration.persisted).toBe(false) 82 | }) 83 | }) 84 | }) 85 | 86 | describe('isExecuted', () => { 87 | it('should return true for isExecuted when applied is true', () => { 88 | const migration = Migration.create({ ...baseProps, applied: true }) 89 | expect(migration.isExecuted()).toBe(true) 90 | }) 91 | 92 | it('should return true for isExecuted when applied is false', () => { 93 | const migration = Migration.create({ ...baseProps, applied: false }) 94 | expect(migration.isExecuted()).toBe(false) 95 | }) 96 | }) 97 | 98 | describe('isPending', () => { 99 | it('should return true for isPending when applied is false', () => { 100 | const migration = Migration.create({ ...baseProps, applied: false }) 101 | expect(migration.isPending()).toBe(true) 102 | }) 103 | 104 | it('should return false for isPending when applied is true', () => { 105 | const migration = Migration.create({ ...baseProps, applied: true }) 106 | expect(migration.isPending()).toBe(false) 107 | }) 108 | }) 109 | 110 | describe('apply', () => { 111 | const params = { 112 | db: mockDbService, 113 | log: mockLog, 114 | error: mockError, 115 | } 116 | 117 | it('should call ask the migration instance to execute', async () => { 118 | const migration = Migration.create({ ...baseProps, applied: false }) 119 | 120 | await migration.apply(params) 121 | 122 | expect(mockInstance.up).toHaveBeenCalledTimes(1) 123 | expect(mockInstance.up).toHaveBeenCalledWith(params) 124 | }) 125 | 126 | it('should not apply when already applied', async () => { 127 | const migration = Migration.create({ ...baseProps, applied: true }) 128 | 129 | await migration.apply(params) 130 | 131 | expect(mockInstance.up).not.toHaveBeenCalled() 132 | }) 133 | 134 | it('should not be applied twice', async () => { 135 | const migration = Migration.create({ ...baseProps, applied: false }) 136 | 137 | await migration.apply(params) 138 | await migration.apply(params) 139 | 140 | expect(mockInstance.up).toHaveBeenCalledTimes(1) 141 | }) 142 | 143 | it('should apply the migration and set applied to true', async () => { 144 | const migration = Migration.create({ ...baseProps }) 145 | 146 | await migration.apply(params) 147 | 148 | expect(migration.applied).toBe(true) 149 | }) 150 | }) 151 | 152 | describe('unapply', () => { 153 | const params = { 154 | db: mockDbService, 155 | log: mockLog, 156 | error: mockError, 157 | } 158 | 159 | it('should call ask the migration instance to undo', async () => { 160 | const migration = Migration.create({ ...baseProps, applied: true }) 161 | 162 | await migration.unapply(params) 163 | 164 | expect(mockInstance.down).toHaveBeenCalledTimes(1) 165 | expect(mockInstance.down).toHaveBeenCalledWith(params) 166 | }) 167 | 168 | it('should not unapply when already pending', async () => { 169 | const migration = Migration.create({ ...baseProps, applied: false }) 170 | 171 | await migration.unapply(params) 172 | 173 | expect(mockInstance.down).not.toHaveBeenCalled() 174 | }) 175 | 176 | it('should not be unapplied twice', async () => { 177 | const migration = Migration.create({ ...baseProps, applied: true }) 178 | 179 | await migration.unapply(params) 180 | await migration.unapply(params) 181 | 182 | expect(mockInstance.down).toHaveBeenCalledTimes(1) 183 | }) 184 | 185 | it('should unapply the migration and set applied to false', async () => { 186 | const migration = Migration.create({ ...baseProps, applied: true }) 187 | 188 | await migration.unapply(params) 189 | 190 | expect(migration.applied).toBe(false) 191 | }) 192 | }) 193 | }) 194 | -------------------------------------------------------------------------------- /src/lib/domain/entities/Migration.ts: -------------------------------------------------------------------------------- 1 | import type { IMigrationCommandParams, IMigrationFileEntity } from '@lib/repositories' 2 | 3 | export type MigrationProps = { 4 | applied: boolean 5 | id: string 6 | instance: IMigrationFileEntity 7 | name: string 8 | persisted: boolean 9 | timestamp: number 10 | } 11 | 12 | export class Migration { 13 | /** The wether the migration has been applied (executed vs. pending, stored as a document vs. local file only) */ 14 | #applied: boolean 15 | /** An appwrite document ID */ 16 | #id: string 17 | /** An instance of the migration file that matches this entity */ 18 | #instance: IMigrationFileEntity 19 | /** The name of the migration (class name) which is also the name in the appwrite document */ 20 | #name: string 21 | /** The timestamp in which the migration was applied if it was applied, it probably does not match class name timestamp */ 22 | #timestamp: number 23 | /** Indicates wether or not this migration exists as a remote entity */ 24 | #persisted: boolean 25 | 26 | public constructor( 27 | applied: boolean, 28 | id: string, 29 | instance: IMigrationFileEntity, 30 | name: string, 31 | persisted: boolean, 32 | timestamp: number, 33 | ) { 34 | this.#applied = applied 35 | this.#id = id 36 | this.#instance = instance 37 | this.#name = name 38 | this.#persisted = persisted 39 | this.#timestamp = timestamp 40 | } 41 | 42 | static create(props: MigrationProps) { 43 | return new Migration( 44 | props.applied, 45 | props.id, 46 | props.instance, 47 | props.name, 48 | props.persisted, 49 | props.timestamp, 50 | ) 51 | } 52 | 53 | public get $id() { 54 | return this.#id 55 | } 56 | 57 | public get applied() { 58 | return this.#applied 59 | } 60 | 61 | public get instance() { 62 | return this.#instance 63 | } 64 | 65 | public get name() { 66 | return this.#name 67 | } 68 | 69 | get persisted() { 70 | return this.#persisted 71 | } 72 | 73 | public get timestamp() { 74 | return this.#timestamp 75 | } 76 | 77 | public get value() { 78 | return { 79 | $id: this.$id, 80 | applied: this.applied, 81 | name: this.name, 82 | timestamp: this.timestamp, 83 | } as const 84 | } 85 | 86 | public isExecuted() { 87 | return this.#applied 88 | } 89 | 90 | public isPending() { 91 | return !this.#applied 92 | } 93 | 94 | public setPersisted(value: boolean) { 95 | this.#persisted = value 96 | } 97 | 98 | public async apply(params: IMigrationCommandParams) { 99 | if (this.isPending()) { 100 | await this.#instance.up(params) 101 | 102 | this.setApplied(true) 103 | } 104 | 105 | return this.value 106 | } 107 | 108 | public async unapply(params: IMigrationCommandParams) { 109 | if (this.isExecuted()) { 110 | await this.#instance.down(params) 111 | 112 | this.setApplied(false) 113 | } 114 | 115 | return this.value 116 | } 117 | 118 | private setApplied(value: boolean) { 119 | this.#applied = value 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/lib/domain/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Migration' 2 | -------------------------------------------------------------------------------- /src/lib/domain/errors/DuplicateMigrationError.ts: -------------------------------------------------------------------------------- 1 | export class DuplicateMigrationError extends Error { 2 | constructor() { 3 | super( 4 | 'Found duplicate migration files. There are at least two files with the same class name. We suggest using our codegen tools when you need to write new migration files.', 5 | ) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/domain/index.ts: -------------------------------------------------------------------------------- 1 | export * from './entities' 2 | export * from './DatabaseService' 3 | export * from './MigrationService' 4 | -------------------------------------------------------------------------------- /src/lib/domain/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | type LocalMigrationVO = Readonly<{ 2 | applied: boolean 3 | name: string 4 | timestamp: number 5 | }> 6 | 7 | type RemoteMigrationVO = Readonly<{ 8 | $id: string 9 | applied: boolean 10 | name: string 11 | timestamp: number 12 | }> 13 | 14 | export type { LocalMigrationVO, RemoteMigrationVO } 15 | -------------------------------------------------------------------------------- /src/lib/migrationsCreateCollection.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'node-appwrite' 2 | import invariant from 'tiny-invariant' 3 | 4 | import { 5 | MIGRATIONS_COLLECTION_ID, 6 | MIGRATIONS_COLLECTION_NAME, 7 | MIGRATIONS_DATABASE_ID, 8 | } from './constants' 9 | import { DatabaseService } from './domain' 10 | import type { Logger } from './types' 11 | 12 | function configuration() { 13 | const apiKey = process.env['APPWRITE_API_KEY'] 14 | invariant(apiKey, 'APPWRITE_API_KEY') 15 | 16 | const collectionId = 17 | process.env['MIGRATIONS_COLLECTION_ID'] ?? MIGRATIONS_COLLECTION_ID 18 | invariant(collectionId, 'MIGRATIONS_COLLECTION_ID') 19 | 20 | const collectionName = 21 | process.env['MIGRATIONS_COLLECTION_NAME'] ?? MIGRATIONS_COLLECTION_NAME 22 | invariant(collectionName, 'MIGRATIONS_COLLECTION_NAME') 23 | 24 | const databaseId = process.env['MIGRATIONS_DATABASE_ID'] ?? MIGRATIONS_DATABASE_ID 25 | invariant(databaseId, 'MIGRATIONS_DATABASE_ID') 26 | 27 | const endpoint = process.env['APPWRITE_ENDPOINT'] 28 | invariant(endpoint, 'APPWRITE_ENDPOINT') 29 | 30 | const projectId = process.env['APPWRITE_FUNCTION_PROJECT_ID'] 31 | invariant(projectId, 'APPWRITE_FUNCTION_PROJECT_ID') 32 | 33 | return { 34 | apiKey, 35 | collectionId, 36 | collectionName, 37 | databaseId, 38 | endpoint, 39 | projectId, 40 | } 41 | } 42 | 43 | export async function migrationsCreateCollection({ 44 | log, 45 | error, 46 | }: { log: Logger; error: Logger }) { 47 | log('Started migrationsCreateCollection.') 48 | 49 | const { endpoint, apiKey, databaseId, collectionId, collectionName, projectId } = 50 | configuration() 51 | 52 | log( 53 | `Will create the migration collection ${collectionName} on database ${databaseId}.`, 54 | ) 55 | 56 | const client = new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey) 57 | const databaseService = DatabaseService.create({ client, databaseId }) 58 | 59 | const databaseExists = await databaseService.databaseExists() 60 | 61 | if (!databaseExists) { 62 | error( 63 | `Can't prooced. Database ${databaseId} does not exist on project ${projectId}.`, 64 | ) 65 | 66 | return 67 | } 68 | 69 | const collectionExists = await databaseService.collectionExists(collectionId) 70 | 71 | if (collectionExists) { 72 | error( 73 | `Can't prooced. Collection ${collectionId} already exists on database ${databaseId}.`, 74 | ) 75 | 76 | return 77 | } 78 | 79 | await databaseService 80 | .createCollection(databaseId, collectionId, collectionName) 81 | .then(() => 82 | log(`Created Migration collection ${collectionName} (id: ${collectionId}).`), 83 | ) 84 | .catch((e) => { 85 | error(`Could not create collection ${collectionName} (id: ${collectionId}).`) 86 | 87 | if (e instanceof Error) { 88 | error(e.message) 89 | } 90 | 91 | throw e 92 | }) 93 | 94 | await databaseService.createBooleanAttribute( 95 | databaseId, 96 | collectionId, 97 | 'applied', 98 | false, 99 | false, 100 | false, 101 | ) 102 | 103 | await databaseService.createStringAttribute( 104 | databaseId, 105 | collectionId, 106 | 'name', 107 | 256, 108 | true, 109 | undefined, 110 | false, 111 | ) 112 | 113 | await databaseService.createIntegerAttribute( 114 | databaseId, 115 | collectionId, 116 | 'timestamp', 117 | true, 118 | 0, 119 | 9007199254740991, 120 | undefined, 121 | false, 122 | ) 123 | 124 | log('Completed migrationsCreateCollection.') 125 | } 126 | -------------------------------------------------------------------------------- /src/lib/migrationsCreateDatabase.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'node-appwrite' 2 | import invariant from 'tiny-invariant' 3 | 4 | import { MIGRATIONS_DATABASE_ID } from './constants' 5 | import { DatabaseService } from './domain' 6 | import type { Logger } from './types' 7 | 8 | function configuration() { 9 | const apiKey = process.env['APPWRITE_API_KEY'] 10 | invariant(apiKey, 'APPWRITE_API_KEY') 11 | 12 | const databaseId = process.env['MIGRATIONS_DATABASE_ID'] ?? MIGRATIONS_DATABASE_ID 13 | invariant(databaseId, 'MIGRATIONS_DATABASE_ID') 14 | 15 | const endpoint = process.env['APPWRITE_ENDPOINT'] 16 | invariant(endpoint, 'APPWRITE_ENDPOINT') 17 | 18 | const projectId = process.env['APPWRITE_FUNCTION_PROJECT_ID'] 19 | invariant(projectId, 'APPWRITE_FUNCTION_PROJECT_ID') 20 | 21 | return { 22 | apiKey, 23 | databaseId, 24 | endpoint, 25 | projectId, 26 | } 27 | } 28 | 29 | export async function migrationsCreateDatabase({ 30 | log, 31 | error, 32 | }: { log: Logger; error: Logger }) { 33 | log('Started migrationsCreateDatabase.') 34 | 35 | const { endpoint, apiKey, databaseId, projectId } = configuration() 36 | 37 | log(`Will create the migration database ${databaseId} on project ${projectId}.`) 38 | 39 | const client = new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey) 40 | const databaseService = DatabaseService.create({ client, databaseId }) 41 | 42 | const databaseExists = await databaseService.databaseExists() 43 | 44 | if (!databaseExists) { 45 | error( 46 | `Can't prooced. Database ${databaseId} already exists on project ${projectId}.`, 47 | ) 48 | 49 | return 50 | } 51 | 52 | await databaseService 53 | .create(databaseId, databaseId) 54 | .then(() => log(`Created database ${databaseId} (id: ${databaseId}).`)) 55 | .catch((e) => { 56 | error(`Could not create database ${databaseId} (id: ${databaseId}).`) 57 | 58 | if (e instanceof Error) { 59 | error(e.message) 60 | } 61 | 62 | throw e 63 | }) 64 | 65 | log('Completed migrationsCreateDatabase.') 66 | } 67 | -------------------------------------------------------------------------------- /src/lib/migrationsDownOne.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'node-appwrite' 2 | import invariant from 'tiny-invariant' 3 | 4 | import { MIGRATIONS_COLLECTION_ID, MIGRATIONS_COLLECTION_NAME } from './constants' 5 | import { DatabaseService, MigrationService } from './domain' 6 | import { LocalMigrationRepository, RemoteMigrationRepository } from './repositories' 7 | import type { Logger } from './types' 8 | 9 | function configuration() { 10 | const apiKey = process.env['APPWRITE_API_KEY'] 11 | invariant(apiKey, 'APPWRITE_API_KEY') 12 | 13 | const collectionId = 14 | process.env['MIGRATIONS_COLLECTION_ID'] ?? MIGRATIONS_COLLECTION_ID 15 | invariant(collectionId, 'MIGRATIONS_COLLECTION_ID') 16 | 17 | const collectionName = 18 | process.env['MIGRATIONS_COLLECTION_NAME'] ?? MIGRATIONS_COLLECTION_NAME 19 | invariant(collectionName, 'MIGRATIONS_COLLECTION_NAME') 20 | 21 | const databaseId = process.env['MIGRATIONS_DATABASE_ID'] 22 | invariant(databaseId, 'MIGRATIONS_DATABASE_ID') 23 | 24 | const endpoint = process.env['APPWRITE_ENDPOINT'] 25 | invariant(endpoint, 'APPWRITE_ENDPOINT') 26 | 27 | const projectId = process.env['APPWRITE_FUNCTION_PROJECT_ID'] 28 | invariant(projectId, 'APPWRITE_FUNCTION_PROJECT_ID') 29 | 30 | return { 31 | apiKey, 32 | collectionId, 33 | databaseId, 34 | endpoint, 35 | projectId, 36 | } 37 | } 38 | 39 | export async function migrationsDownOne({ 40 | log, 41 | error, 42 | }: { log: Logger; error: Logger }) { 43 | log('Started migrationsDownOne.') 44 | 45 | const { apiKey, collectionId, databaseId, endpoint, projectId } = configuration() 46 | 47 | log(`Will look for last applied migration on database ${databaseId} and down it.`) 48 | 49 | const client = new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey) 50 | const databaseService = DatabaseService.create({ client, databaseId }) 51 | 52 | const databaseExists = await databaseService.databaseExists() 53 | 54 | if (!databaseExists) { 55 | error( 56 | `Can't prooced. Database ${databaseId} does not exist on project ${projectId}.`, 57 | ) 58 | 59 | return 60 | } 61 | 62 | const collectionExists = databaseService.collectionExists(collectionId) 63 | 64 | if (!collectionExists) { 65 | error( 66 | `Can't prooced. Collection ${collectionId} does not exist on database ${databaseId}.`, 67 | ) 68 | 69 | return 70 | } 71 | 72 | const localMigrationRepository = LocalMigrationRepository.create({ 73 | error, 74 | log, 75 | }) 76 | 77 | const remoteMigrationRepository = RemoteMigrationRepository.create({ 78 | collectionId, 79 | databaseId, 80 | databaseService, 81 | error, 82 | log, 83 | }) 84 | 85 | const migrationService = MigrationService.create({ 86 | error, 87 | log, 88 | localMigrationRepository, 89 | remoteMigrationRepository, 90 | }) 91 | 92 | log('Setting up migration service...') 93 | 94 | await Promise.all([ 95 | migrationService.withLocalEntities(), 96 | migrationService.withRemoteEntities(), 97 | ]) 98 | 99 | await migrationService.withMigrations().undoLastMigration(databaseService) 100 | 101 | log('Completed migrationsDownOne.') 102 | } 103 | -------------------------------------------------------------------------------- /src/lib/migrationsResetDatabase.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'node-appwrite' 2 | import invariant from 'tiny-invariant' 3 | 4 | import { MIGRATIONS_DATABASE_ID } from './constants' 5 | import { DatabaseService } from './domain' 6 | import type { Logger } from './types' 7 | 8 | function configuration() { 9 | const apiKey = process.env['APPWRITE_API_KEY'] 10 | invariant(apiKey, 'APPWRITE_API_KEY') 11 | 12 | const databaseId = process.env['MIGRATIONS_DATABASE_ID'] ?? MIGRATIONS_DATABASE_ID 13 | invariant(databaseId, 'MIGRATIONS_DATABASE_ID') 14 | 15 | const endpoint = process.env['APPWRITE_ENDPOINT'] 16 | invariant(endpoint, 'APPWRITE_ENDPOINT') 17 | 18 | const projectId = process.env['APPWRITE_FUNCTION_PROJECT_ID'] 19 | invariant(projectId, 'APPWRITE_FUNCTION_PROJECT_ID') 20 | 21 | return { 22 | apiKey, 23 | databaseId, 24 | endpoint, 25 | projectId, 26 | } 27 | } 28 | 29 | export async function migrationsResetDatabase({ 30 | log, 31 | error, 32 | }: { log: Logger; error: Logger }) { 33 | log('Started migrationsResetDatabase.') 34 | 35 | const { endpoint, apiKey, databaseId, projectId } = configuration() 36 | 37 | const client = new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey) 38 | const databaseService = DatabaseService.create({ client, databaseId }) 39 | 40 | const databaseExists = await databaseService.databaseExists() 41 | 42 | if (!databaseExists) { 43 | error( 44 | `Can't prooced. Database ${databaseId} does not exist on project ${projectId}.`, 45 | ) 46 | 47 | return 48 | } 49 | 50 | const { collections } = await databaseService.getCollections() 51 | 52 | for await (const collection of collections) { 53 | try { 54 | log(`Will delete collection ${collection.name} (id: ${collection.$id}).`) 55 | 56 | await databaseService.dropCollection(collection.$id) 57 | 58 | log(`Deleted collection ${collection.name} (id: ${collection.$id}).`) 59 | } catch (e) { 60 | error( 61 | `Failed to delete collection named ${collection.name} (id: ${collection.$id}).`, 62 | ) 63 | 64 | throw e 65 | } 66 | } 67 | 68 | log('Completed migrationsResetDatabase. All collections have been dropped.') 69 | } 70 | -------------------------------------------------------------------------------- /src/lib/migrationsRunSequence.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'node-appwrite' 2 | import invariant from 'tiny-invariant' 3 | 4 | import { MIGRATIONS_COLLECTION_ID, MIGRATIONS_COLLECTION_NAME } from './constants' 5 | import { DatabaseService, MigrationService } from './domain' 6 | import { LocalMigrationRepository, RemoteMigrationRepository } from './repositories' 7 | import type { Logger } from './types' 8 | 9 | function configuration() { 10 | const apiKey = process.env['APPWRITE_API_KEY'] 11 | invariant(apiKey, 'APPWRITE_API_KEY') 12 | 13 | const collectionId = 14 | process.env['MIGRATIONS_COLLECTION_ID'] ?? MIGRATIONS_COLLECTION_ID 15 | invariant(collectionId, 'MIGRATIONS_COLLECTION_ID') 16 | 17 | const collectionName = 18 | process.env['MIGRATIONS_COLLECTION_NAME'] ?? MIGRATIONS_COLLECTION_NAME 19 | invariant(collectionName, 'MIGRATIONS_COLLECTION_NAME') 20 | 21 | const databaseId = process.env['MIGRATIONS_DATABASE_ID'] 22 | invariant(databaseId, 'MIGRATIONS_DATABASE_ID') 23 | 24 | const endpoint = process.env['APPWRITE_ENDPOINT'] 25 | invariant(endpoint, 'APPWRITE_ENDPOINT') 26 | 27 | const projectId = process.env['APPWRITE_FUNCTION_PROJECT_ID'] 28 | invariant(projectId, 'APPWRITE_FUNCTION_PROJECT_ID') 29 | 30 | return { 31 | apiKey, 32 | collectionId, 33 | databaseId, 34 | endpoint, 35 | projectId, 36 | } 37 | } 38 | 39 | export async function migrationsRunSequence({ 40 | log, 41 | error, 42 | }: { log: Logger; error: Logger }) { 43 | log('Run migration sequence started.') 44 | 45 | const { apiKey, collectionId, databaseId, endpoint, projectId } = configuration() 46 | 47 | const client = new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey) 48 | const databaseService = DatabaseService.create({ client, databaseId }) 49 | 50 | const databaseExists = await databaseService.databaseExists() 51 | 52 | if (!databaseExists) { 53 | error(`Can't prooced. Database ${databaseId} does not exist.`) 54 | 55 | return 56 | } 57 | 58 | const collectionExists = databaseService.collectionExists(collectionId) 59 | 60 | if (!collectionExists) { 61 | error( 62 | `Can't prooced. Collection ${collectionId} does not exist on database ${databaseId}.`, 63 | ) 64 | 65 | return 66 | } 67 | 68 | const localMigrationRepository = LocalMigrationRepository.create({ 69 | error, 70 | log, 71 | }) 72 | 73 | const remoteMigrationRepository = RemoteMigrationRepository.create({ 74 | collectionId, 75 | databaseId, 76 | databaseService, 77 | error, 78 | log, 79 | }) 80 | 81 | log('Setting up migration service...') 82 | 83 | const migrationService = MigrationService.create({ 84 | error, 85 | log, 86 | localMigrationRepository, 87 | remoteMigrationRepository, 88 | }) 89 | 90 | await Promise.all([ 91 | migrationService.withLocalEntities(), 92 | migrationService.withRemoteEntities(), 93 | ]) 94 | 95 | await migrationService.withMigrations().executePendingMigrations(databaseService) 96 | 97 | log('Run migration sequence completed successfully.') 98 | } 99 | -------------------------------------------------------------------------------- /src/lib/repositories/LocalMigrationRepository.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs' 2 | import { createMock } from '@golevelup/ts-jest' 3 | 4 | import { LocalMigrationRepository } from './LocalMigrationRepository' 5 | import { LocalMigrationEntity } from './entities' 6 | 7 | describe('LocalMigrationRepository', () => { 8 | const env = { ...process.env } 9 | 10 | const errorLogger = jest.fn() 11 | const infoLogger = jest.fn() 12 | 13 | const migrationFolder = '/test-utils/mocks' 14 | 15 | const entity = createMock() 16 | 17 | beforeEach(() => { 18 | jest.resetAllMocks() 19 | jest.resetModules() 20 | 21 | process.env = env 22 | process.env['MIGRATIONS_HOME_FOLDER'] = migrationFolder 23 | }) 24 | 25 | afterEach(() => { 26 | process.env = env 27 | }) 28 | 29 | describe('deleteMigration', () => { 30 | it('should throw an error indicating that the method is not implemented', async () => { 31 | const repository = LocalMigrationRepository.create({ 32 | error: errorLogger, 33 | log: infoLogger, 34 | }) 35 | 36 | expect( 37 | async () => await repository.deleteMigration(entity as any), 38 | ).rejects.toThrow('Method not implemented.') 39 | }) 40 | }) 41 | 42 | describe('insertMigration', () => { 43 | it('should throw an error indicating that the method is not implemented', async () => { 44 | const repository = LocalMigrationRepository.create({ 45 | error: errorLogger, 46 | log: infoLogger, 47 | }) 48 | 49 | expect( 50 | async () => await repository.insertMigration(entity as any), 51 | ).rejects.toThrow('Method not implemented.') 52 | }) 53 | }) 54 | 55 | describe('listMigrations', () => { 56 | it('should return an array of MigrationEntity instances when at least one MigrationFile exists', async () => { 57 | const repository = LocalMigrationRepository.create({ 58 | error: errorLogger, 59 | log: infoLogger, 60 | }) 61 | 62 | const result = await repository.listMigrations() 63 | 64 | expect(result).toBeInstanceOf(Array) 65 | expect(result).toHaveLength(1) 66 | expect(result[0]).toBeInstanceOf(LocalMigrationEntity) 67 | }) 68 | 69 | it('should return an empty array when no MigrationFiles are found', async () => { 70 | jest.spyOn(fs.promises, 'readdir').mockResolvedValue([]) 71 | 72 | const repository = LocalMigrationRepository.create({ 73 | error: errorLogger, 74 | log: infoLogger, 75 | }) 76 | 77 | const result = await repository.listMigrations() 78 | 79 | expect(result).toBeInstanceOf(Array) 80 | expect(result).toHaveLength(0) 81 | }) 82 | }) 83 | 84 | describe('updateMigration', () => { 85 | it('should throw an error indicating that the method is not implemented', async () => { 86 | const repository = LocalMigrationRepository.create({ 87 | error: errorLogger, 88 | log: infoLogger, 89 | }) 90 | 91 | expect( 92 | async () => await repository.updateMigration(entity as any), 93 | ).rejects.toThrow('Method not implemented.') 94 | }) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /src/lib/repositories/LocalMigrationRepository.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs' 2 | import * as path from 'node:path' 3 | import invariant from 'tiny-invariant' 4 | 5 | import { MIGRATIONS_HOME_FOLDER } from '@lib/constants' 6 | import type { Logger } from '@lib/types' 7 | import { isClass } from '@lib/utils/type-guards' 8 | 9 | import { LocalMigrationEntity } from './entities' 10 | import type { MigrationFileEntity } from './entities/MigrationFileEntity' 11 | import type { 12 | CreateMigrationEntity, 13 | DeleteMigrationEntity, 14 | IMigrationRepository, 15 | UpdateMigrationEntity, 16 | } from './interfaces' 17 | 18 | type MigrationLocalRepositoryProps = { 19 | error: Logger 20 | log: Logger 21 | } 22 | 23 | export class LocalMigrationRepository implements IMigrationRepository { 24 | /** A function that can be used to log error messages */ 25 | readonly #error: Logger 26 | /** A function that can be used to log information messages */ 27 | readonly #log: Logger 28 | /** A relative path to the location where local Migration documents can be found */ 29 | readonly #store: string 30 | /* -------------------------------------------------------------------------- */ 31 | /* constructor */ 32 | /* -------------------------------------------------------------------------- */ 33 | 34 | private constructor(props: MigrationLocalRepositoryProps) { 35 | this.#error = props.error 36 | this.#log = props.log 37 | this.#store = process.env['MIGRATIONS_HOME_FOLDER'] ?? MIGRATIONS_HOME_FOLDER 38 | 39 | invariant(this.#store, 'MIGRATIONS_HOME_FOLDER') 40 | } 41 | 42 | static create(props: MigrationLocalRepositoryProps) { 43 | return new LocalMigrationRepository(props) 44 | } 45 | 46 | /* -------------------------------------------------------------------------- */ 47 | /* public methods */ 48 | /* -------------------------------------------------------------------------- */ 49 | 50 | async deleteMigration(_migration: DeleteMigrationEntity): Promise { 51 | throw new Error('Method not implemented.') 52 | } 53 | 54 | async insertMigration( 55 | _migration: CreateMigrationEntity, 56 | ): Promise { 57 | throw new Error('Method not implemented.') 58 | } 59 | 60 | async listMigrations() { 61 | const folder = path.join(process.cwd(), this.#store) 62 | const files = await this.getFiles(folder, ['js', 'ts']) 63 | const imports = files.map((file) => import(path.resolve(this.#store, file))) 64 | const modules = await Promise.all(imports) 65 | 66 | const entities = modules 67 | .filter((module) => this.isMigrationFileClass(module.default)) 68 | .map((module) => { 69 | const instance = new module.default() 70 | const name = instance.constructor.name 71 | const timestamp = this.getTimestampFromClassname(name) 72 | 73 | this.#log(`Loaded local migration entity: ${name}`) 74 | 75 | return LocalMigrationEntity.create({ 76 | instance, 77 | name, 78 | timestamp, 79 | }) 80 | }) 81 | 82 | this.#log( 83 | `Local entities retrieved: ${JSON.stringify(entities.map((x) => x.name))}`, 84 | ) 85 | 86 | return entities 87 | } 88 | 89 | async updateMigration( 90 | _migration: UpdateMigrationEntity, 91 | ): Promise { 92 | throw new Error('Method not implemented.') 93 | } 94 | 95 | /* -------------------------------------------------------------------------- */ 96 | /* type-guards */ 97 | /* -------------------------------------------------------------------------- */ 98 | 99 | private async getFiles(dir: string, extensions: string[]): Promise { 100 | const dirents = await fs.promises.readdir(dir, { withFileTypes: true }) 101 | 102 | const files = dirents 103 | .filter( 104 | (dent) => 105 | dent.isFile() && extensions.includes(dent.name.split('.').pop() || ''), 106 | ) 107 | .map((file) => path.resolve(dir, file.name)) 108 | 109 | return files 110 | } 111 | 112 | private isMigrationFileClass(value: unknown): value is typeof MigrationFileEntity { 113 | return isClass(value) && 'up' in value.prototype && 'down' in value.prototype 114 | } 115 | 116 | private getTimestampFromClassname(name: string) { 117 | const matches = name.match(/_(\d+)_/) 118 | 119 | if (matches && typeof matches.at(1) === 'string') { 120 | return Number(matches.at(1)) 121 | } 122 | 123 | throw new Error( 124 | `Unable to extract timestamp from migration file. Expected class name to have format 'Migration__', got: '${name}'. We suggest using our codegen tools when you need to write new migration files.`, 125 | ) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/lib/repositories/RemoteMigrationRepository.spec.ts: -------------------------------------------------------------------------------- 1 | import { createMock } from '@golevelup/ts-jest' 2 | 3 | import type { DatabaseService } from '@lib/domain' 4 | import { createId } from '@lib/utils' 5 | 6 | import { RemoteMigrationRepository } from './RemoteMigrationRepository' 7 | import { RemoteMigrationEntity } from './entities' 8 | 9 | describe('RemoteMigrationRepository', () => { 10 | const collectionId = 'collection-id' 11 | const databaseId = 'database-id' 12 | const error = jest.fn() 13 | const log = jest.fn() 14 | 15 | const databaseService = createMock() 16 | const testSubject = RemoteMigrationRepository.create({ 17 | databaseId, 18 | collectionId, 19 | databaseService, 20 | error, 21 | log, 22 | }) 23 | 24 | beforeEach(() => { 25 | jest.resetAllMocks() 26 | }) 27 | 28 | describe('deleteMigration', () => { 29 | it('should delete a migration document', async () => { 30 | const entity = RemoteMigrationEntity.create({ 31 | id: createId(), 32 | applied: true, 33 | name: 'SomeMigrationName', 34 | timestamp: Date.now(), 35 | }) 36 | 37 | databaseService.deleteDocument.mockResolvedValueOnce('') 38 | 39 | const result = await testSubject.deleteMigration(entity) 40 | 41 | expect(databaseService.deleteDocument).toHaveBeenCalledTimes(1) 42 | expect(databaseService.deleteDocument).toHaveBeenCalledWith( 43 | databaseId, 44 | collectionId, 45 | entity.$id, 46 | ) 47 | 48 | expect(result).toBe(true) 49 | }) 50 | 51 | it('should throw an error if migration entity does not have an $id property', async () => { 52 | const entity = RemoteMigrationEntity.create({ 53 | id: '', 54 | applied: true, 55 | name: 'SomeMigrationName', 56 | timestamp: Date.now(), 57 | }) 58 | 59 | await expect( 60 | async () => await testSubject.deleteMigration(entity), 61 | ).rejects.toThrow( 62 | 'Can not delete migration. Expected entity to have property `id` of type string.', 63 | ) 64 | }) 65 | }) 66 | 67 | describe('insertMigration', () => { 68 | it('should insert a migration document', async () => { 69 | const id = createId() 70 | const applied = true 71 | const name = 'SomeMigrationName' 72 | const timestamp = Date.now() 73 | 74 | const entity = RemoteMigrationEntity.create({ 75 | id, 76 | applied, 77 | name, 78 | timestamp, 79 | }) 80 | 81 | databaseService.createDocument.mockResolvedValueOnce(entity as any) 82 | 83 | const result = await testSubject.insertMigration(entity) 84 | 85 | expect(databaseService.createDocument).toHaveBeenCalledTimes(1) 86 | expect(databaseService.createDocument).toHaveBeenCalledWith( 87 | databaseId, 88 | collectionId, 89 | entity.$id, 90 | { applied, name, timestamp }, 91 | ) 92 | 93 | expect(result).toBeInstanceOf(RemoteMigrationEntity) 94 | expect(result.$id).toEqual(entity.$id) 95 | }) 96 | 97 | it('should throw an error if migration entity is malformed', async () => { 98 | const entity = RemoteMigrationEntity.create({ 99 | id: createId(), 100 | applied: true, 101 | name: 'SomeMigrationName', 102 | timestamp: Date.now(), 103 | }) 104 | 105 | databaseService.createDocument.mockResolvedValueOnce({} as any) 106 | 107 | await expect( 108 | async () => await testSubject.insertMigration(entity), 109 | ).rejects.toThrow( 110 | 'Migration inserted resulted in malformed Migration document. This should not possible.', 111 | ) 112 | }) 113 | }) 114 | 115 | describe('listMigrations', () => { 116 | it('should list migration documents', async () => { 117 | const response: any = { 118 | documents: [ 119 | { 120 | $id: createId(), 121 | applied: true, 122 | name: 'migration_1', 123 | timestamp: Date.now(), 124 | }, 125 | { 126 | $id: createId(), 127 | applied: false, 128 | name: 'migration_2', 129 | timestamp: Date.now(), 130 | }, 131 | ], 132 | } 133 | 134 | databaseService.listDocuments.mockResolvedValueOnce(response) 135 | 136 | const result = await testSubject.listMigrations() 137 | 138 | expect(databaseService.listDocuments).toHaveBeenCalledTimes(1) 139 | expect(databaseService.listDocuments).toHaveBeenCalledWith( 140 | databaseId, 141 | collectionId, 142 | ) 143 | 144 | expect(result).toHaveLength(2) 145 | expect(result[0]).toBeInstanceOf(RemoteMigrationEntity) 146 | expect(result[0].$id).toBe(response.documents[0].$id) 147 | expect(result[1]).toBeInstanceOf(RemoteMigrationEntity) 148 | expect(result[1].$id).toBe(response.documents[1].$id) 149 | }) 150 | 151 | it('should throw an error if unexpected document shape is found', async () => { 152 | const response: any = { 153 | documents: [{ $id: 'document_id_1', applied: true, name: 'migration_1' }], 154 | } 155 | 156 | databaseService.listDocuments.mockResolvedValueOnce(response) 157 | 158 | await expect(async () => await testSubject.listMigrations()).rejects.toThrow( 159 | 'Unexpected document shape found in migration document document_id_1', 160 | ) 161 | }) 162 | }) 163 | 164 | describe('updateMigration', () => { 165 | it('should update a migration document', async () => { 166 | const id = createId() 167 | const applied = false 168 | const name = 'SomeMigrationName' 169 | const timestamp = Date.now() 170 | 171 | const entity = RemoteMigrationEntity.create({ 172 | id, 173 | applied, 174 | name, 175 | timestamp, 176 | }) 177 | 178 | databaseService.updateDocument.mockResolvedValueOnce(entity.value as any) 179 | 180 | const result = await testSubject.updateMigration({ 181 | $id: id, 182 | applied, 183 | }) 184 | 185 | expect(databaseService.updateDocument).toHaveBeenCalledTimes(1) 186 | expect(databaseService.updateDocument).toHaveBeenCalledWith( 187 | databaseId, 188 | collectionId, 189 | entity.$id, 190 | { 191 | applied: entity.applied, 192 | }, 193 | ) 194 | 195 | expect(result).toBeInstanceOf(RemoteMigrationEntity) 196 | expect(result.$id).toEqual(entity.$id) 197 | expect(result.applied).toEqual(applied) 198 | }) 199 | 200 | it('should throw an error if migration entity is malformed after update', async () => { 201 | const entity = RemoteMigrationEntity.create({ 202 | id: createId(), 203 | applied: true, 204 | name: 'SomeMigrationName', 205 | timestamp: Date.now(), 206 | }) 207 | 208 | databaseService.updateDocument.mockResolvedValueOnce({} as any) 209 | 210 | await expect( 211 | async () => 212 | await testSubject.updateMigration({ 213 | $id: entity.$id, 214 | applied: false, 215 | }), 216 | ).rejects.toThrow( 217 | 'Migration update resulted in malformed Migration document. This should not be possible.', 218 | ) 219 | }) 220 | 221 | it('should throw a TypeError if migration entity has no $id during update', async () => { 222 | const applied = false 223 | 224 | await expect( 225 | async () => 226 | await testSubject.updateMigration({ 227 | $id: undefined as any, 228 | applied, 229 | }), 230 | ).rejects.toThrow( 231 | 'Can not update migration. Expected entity to have property `id` of type string.', 232 | ) 233 | }) 234 | }) 235 | }) 236 | -------------------------------------------------------------------------------- /src/lib/repositories/RemoteMigrationRepository.ts: -------------------------------------------------------------------------------- 1 | import type { Models } from 'node-appwrite' 2 | 3 | import type { DatabaseService } from '@lib/domain' 4 | import type { Logger } from '@lib/types' 5 | import { isRecord } from '@lib/utils' 6 | 7 | import { RemoteMigrationEntity } from './entities' 8 | import type { 9 | CreateMigrationEntity, 10 | IMigrationRepository, 11 | UpdateMigrationEntity, 12 | } from './interfaces' 13 | 14 | type MigrationRemoteRepositoryProps = { 15 | databaseId: string 16 | collectionId: string 17 | databaseService: DatabaseService 18 | error: Logger 19 | log: Logger 20 | } 21 | 22 | type MigrationDocument = Models.Document & { [x: string]: unknown } & { 23 | $id: string 24 | applied: boolean 25 | name: string 26 | timestamp: number 27 | } 28 | 29 | export class RemoteMigrationRepository implements IMigrationRepository { 30 | /** The ID of the collection where executed migrations can be found */ 31 | readonly #collectionId: string 32 | /** The ID database against which migrations files will be ran */ 33 | readonly #databaseId: string 34 | /** An instance of Appwrite Databases to be as a database service */ 35 | readonly #databaseService: DatabaseService 36 | /** A function that can be used to log error messages */ 37 | readonly #error: Logger 38 | /** A function that can be used to log information messages */ 39 | readonly #log: Logger 40 | 41 | /* -------------------------------------------------------------------------- */ 42 | /* constructor */ 43 | /* -------------------------------------------------------------------------- */ 44 | 45 | private constructor(props: MigrationRemoteRepositoryProps) { 46 | this.#collectionId = props.collectionId 47 | this.#databaseId = props.databaseId 48 | this.#databaseService = props.databaseService 49 | this.#error = props.error 50 | this.#log = props.log 51 | } 52 | 53 | static create(props: MigrationRemoteRepositoryProps) { 54 | return new RemoteMigrationRepository(props) 55 | } 56 | 57 | /* -------------------------------------------------------------------------- */ 58 | /* public methods */ 59 | /* -------------------------------------------------------------------------- */ 60 | 61 | public async deleteMigration(migration: { $id: string }): Promise { 62 | if (!migration.$id) { 63 | throw new TypeError( 64 | 'Can not delete migration. Expected entity to have property `id` of type string.', 65 | ) 66 | } 67 | 68 | await this.#databaseService.deleteDocument( 69 | this.#databaseId, 70 | this.#collectionId, 71 | migration.$id, 72 | ) 73 | 74 | return true 75 | } 76 | 77 | public async insertMigration( 78 | migration: CreateMigrationEntity, 79 | ): Promise { 80 | const document = await this.#databaseService.createDocument( 81 | this.#databaseId, 82 | this.#collectionId, 83 | migration.$id, 84 | { 85 | applied: migration.applied, 86 | name: migration.name, 87 | timestamp: migration.timestamp, 88 | }, 89 | ) 90 | 91 | if (this.isMigrationEntity(document)) { 92 | return RemoteMigrationEntity.create({ ...document, id: document.$id }) 93 | } 94 | 95 | throw new Error( 96 | 'Migration inserted resulted in malformed Migration document. This should not possible.', 97 | ) 98 | } 99 | 100 | public async listMigrations() { 101 | const response = await this.#databaseService.listDocuments( 102 | this.#databaseId, 103 | this.#collectionId, 104 | ) 105 | 106 | const entities = response.documents.map((document) => { 107 | if (this.isMigrationEntity(document)) { 108 | return RemoteMigrationEntity.create({ 109 | id: document.$id, 110 | applied: document.applied, 111 | name: document.name, 112 | timestamp: document.timestamp, 113 | }) 114 | } 115 | 116 | throw new TypeError( 117 | `Unexpected document shape found in migration document ${document.$id}`, 118 | ) 119 | }) 120 | 121 | this.#log( 122 | `Remote entities retrieved: ${JSON.stringify(entities.map((x) => x.name))}`, 123 | ) 124 | 125 | return entities 126 | } 127 | 128 | public async updateMigration( 129 | migration: UpdateMigrationEntity, 130 | ): Promise { 131 | const { $id, applied } = migration 132 | 133 | if (!$id) { 134 | throw new TypeError( 135 | 'Can not update migration. Expected entity to have property `id` of type string.', 136 | ) 137 | } 138 | 139 | const document = await this.#databaseService.updateDocument( 140 | this.#databaseId, 141 | this.#collectionId, 142 | migration.$id, 143 | { applied }, 144 | ) 145 | 146 | if (this.isMigrationEntity(document)) { 147 | return RemoteMigrationEntity.create({ ...document, id: document.$id }) 148 | } 149 | 150 | throw new Error( 151 | 'Migration update resulted in malformed Migration document. This should not be possible.', 152 | ) 153 | } 154 | 155 | /* -------------------------------------------------------------------------- */ 156 | /* type-guards */ 157 | /* -------------------------------------------------------------------------- */ 158 | private isMigrationEntity( 159 | document: Models.Document & { [x: string]: unknown }, 160 | ): document is MigrationDocument { 161 | const expectedFields = ['$id', 'applied', 'name', 'timestamp'] 162 | 163 | return ( 164 | isRecord(document) && 165 | expectedFields.every((field) => field in document) && 166 | typeof document['$id'] === 'string' && 167 | typeof document['applied'] === 'boolean' && 168 | typeof document['name'] === 'string' && 169 | typeof document['timestamp'] === 'number' 170 | ) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/lib/repositories/entities/LocalMigrationEntity.spec.ts: -------------------------------------------------------------------------------- 1 | import { createMock } from '@golevelup/ts-jest' 2 | 3 | import type { IMigrationFileEntity } from '@lib/repositories/interfaces' 4 | 5 | import { LocalMigrationEntity } from './LocalMigrationEntity' 6 | 7 | describe('LocalMigrationEntity', () => { 8 | const instanceMock = createMock() 9 | 10 | const baseLocalDocumentProps = { 11 | instance: instanceMock, 12 | name: 'SampleMigration', 13 | timestamp: 1234567890, 14 | } 15 | 16 | it('should create an instance from local document', () => { 17 | const entity = new LocalMigrationEntity( 18 | baseLocalDocumentProps.instance, 19 | baseLocalDocumentProps.name, 20 | baseLocalDocumentProps.timestamp, 21 | ) 22 | 23 | expect(entity).toBeInstanceOf(LocalMigrationEntity) 24 | 25 | expect(entity.$id).toBeUndefined() 26 | expect(entity.instance).toBe(baseLocalDocumentProps.instance) 27 | expect(entity.name).toBe(baseLocalDocumentProps.name) 28 | expect(entity.timestamp).toBe(baseLocalDocumentProps.timestamp) 29 | }) 30 | 31 | it('should begin in state unapplied ', () => { 32 | const entity = new LocalMigrationEntity( 33 | baseLocalDocumentProps.instance, 34 | baseLocalDocumentProps.name, 35 | baseLocalDocumentProps.timestamp, 36 | ) 37 | 38 | expect(entity.applied).toBe(false) 39 | }) 40 | 41 | it('should have an undefined id ', () => { 42 | const entity = new LocalMigrationEntity( 43 | baseLocalDocumentProps.instance, 44 | baseLocalDocumentProps.name, 45 | baseLocalDocumentProps.timestamp, 46 | ) 47 | 48 | expect(entity.$id).toBeUndefined() 49 | }) 50 | 51 | it('should have defined migration file instance ', () => { 52 | const entity = new LocalMigrationEntity( 53 | baseLocalDocumentProps.instance, 54 | baseLocalDocumentProps.name, 55 | baseLocalDocumentProps.timestamp, 56 | ) 57 | 58 | expect(entity.instance).toBeDefined() 59 | }) 60 | 61 | it('should expose a value getter', () => { 62 | const entity = new LocalMigrationEntity( 63 | baseLocalDocumentProps.instance, 64 | baseLocalDocumentProps.name, 65 | baseLocalDocumentProps.timestamp, 66 | ) 67 | 68 | expect(entity.value).toMatchObject({ 69 | applied: false, 70 | name: entity.name, 71 | timestamp: entity.timestamp, 72 | }) 73 | }) 74 | 75 | describe('create', () => { 76 | it('should create an instance using the create method', () => { 77 | const props = { ...baseLocalDocumentProps } 78 | 79 | const entity = LocalMigrationEntity.create(props) 80 | 81 | expect(entity).toBeInstanceOf(LocalMigrationEntity) 82 | expect(entity.$id).toBeUndefined() 83 | expect(entity.instance).toBe(props.instance) 84 | expect(entity.name).toBe(props.name) 85 | expect(entity.timestamp).toBe(props.timestamp) 86 | expect(entity.value).toMatchObject({ 87 | applied: false, 88 | name: props.name, 89 | timestamp: props.timestamp, 90 | }) 91 | }) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /src/lib/repositories/entities/LocalMigrationEntity.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | IMigrationEntity, 3 | IMigrationFileEntity, 4 | } from '@lib/repositories/interfaces' 5 | 6 | /** Represents a migration entity for local documents. */ 7 | export class LocalMigrationEntity implements IMigrationEntity { 8 | /** The wether the migration has been applied */ 9 | #applied: boolean 10 | /** An appwrite document ID */ 11 | #id: undefined 12 | /** An instance of the migration file that matches this entity */ 13 | #instance: IMigrationFileEntity 14 | /** The name of the migration (class name) which is also the name in the appwrite document */ 15 | #name: string 16 | /** The timestamp in which the migration was applied */ 17 | #timestamp: number 18 | 19 | public constructor(instance: IMigrationFileEntity, name: string, timestamp: number) { 20 | this.#applied = false 21 | this.#id = undefined 22 | this.#instance = instance 23 | this.#name = name 24 | this.#timestamp = timestamp 25 | } 26 | 27 | static create(props: { 28 | instance: IMigrationFileEntity 29 | name: string 30 | timestamp: number 31 | }) { 32 | return new LocalMigrationEntity(props.instance, props.name, props.timestamp) 33 | } 34 | 35 | public get $id() { 36 | return this.#id 37 | } 38 | 39 | public get applied() { 40 | return this.#applied 41 | } 42 | 43 | public get instance() { 44 | return this.#instance 45 | } 46 | 47 | public get name() { 48 | return this.#name 49 | } 50 | 51 | public get timestamp() { 52 | return this.#timestamp 53 | } 54 | 55 | public get value() { 56 | return { 57 | $id: this.$id, 58 | applied: this.applied, 59 | name: this.name, 60 | timestamp: this.timestamp, 61 | instance: this.instance, 62 | } as const 63 | } 64 | 65 | public apply() { 66 | this.#applied = true 67 | } 68 | 69 | public unapply() { 70 | this.#applied = false 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/lib/repositories/entities/MigrationFileEntity.spec.ts: -------------------------------------------------------------------------------- 1 | import { createMock } from '@golevelup/ts-jest' 2 | 3 | import type { DatabaseService } from '@lib/domain' 4 | 5 | import { MigrationFileEntity } from './MigrationFileEntity' 6 | 7 | describe('MigrationFileEntity', () => { 8 | const errorMessage = 'Method not implemented.' 9 | 10 | const db = createMock() 11 | const log = jest.fn() 12 | const error = jest.fn() 13 | 14 | const entity = new MigrationFileEntity() 15 | 16 | beforeEach(() => { 17 | jest.resetAllMocks() 18 | }) 19 | 20 | it('should have an up method', () => { 21 | expect(entity.up).toBeDefined() 22 | }) 23 | 24 | it('should have a down method', () => { 25 | expect(entity.down).toBeDefined() 26 | }) 27 | 28 | it('should implement the up method', async () => { 29 | await expect(async () => await entity.up({ db, log, error })).rejects.toThrow( 30 | errorMessage, 31 | ) 32 | }) 33 | 34 | it('should implement the down method', async () => { 35 | await expect(async () => await entity.down({ db, log, error })).rejects.toThrow( 36 | errorMessage, 37 | ) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/lib/repositories/entities/MigrationFileEntity.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | IMigrationCommandParams, 3 | IMigrationFileEntity, 4 | } from '@lib/repositories/interfaces' 5 | 6 | export class MigrationFileEntity implements IMigrationFileEntity { 7 | up({ db, log, error }: IMigrationCommandParams): Promise { 8 | throw new Error('Method not implemented.') 9 | } 10 | 11 | down({ db, log, error }: IMigrationCommandParams): Promise { 12 | throw new Error('Method not implemented.') 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/repositories/entities/RemoteMigrationEntity.spec.ts: -------------------------------------------------------------------------------- 1 | import { createId } from '@lib/utils' 2 | 3 | import { RemoteMigrationEntity } from './RemoteMigrationEntity' 4 | 5 | describe('RemoteMigrationEntity', () => { 6 | const baseRemoteDocumentProps = { 7 | id: createId(), 8 | applied: true, 9 | name: 'SampleMigration', 10 | timestamp: 1234567890, 11 | } 12 | 13 | it('should create an instance from remote document', () => { 14 | const entity = new RemoteMigrationEntity( 15 | baseRemoteDocumentProps.id, 16 | baseRemoteDocumentProps.applied, 17 | baseRemoteDocumentProps.name, 18 | baseRemoteDocumentProps.timestamp, 19 | ) 20 | 21 | expect(entity).toBeInstanceOf(RemoteMigrationEntity) 22 | 23 | expect(entity.$id).toBe(baseRemoteDocumentProps.id) 24 | expect(entity.applied).toBe(baseRemoteDocumentProps.applied) 25 | expect(entity.name).toBe(baseRemoteDocumentProps.name) 26 | expect(entity.timestamp).toBe(baseRemoteDocumentProps.timestamp) 27 | }) 28 | 29 | it('should have undefined migration file instance ', () => { 30 | const entity = new RemoteMigrationEntity( 31 | baseRemoteDocumentProps.id, 32 | baseRemoteDocumentProps.applied, 33 | baseRemoteDocumentProps.name, 34 | baseRemoteDocumentProps.timestamp, 35 | ) 36 | 37 | expect(entity.instance).toBeUndefined() 38 | }) 39 | 40 | it('should expose a value getter', () => { 41 | const entity = new RemoteMigrationEntity( 42 | baseRemoteDocumentProps.id, 43 | baseRemoteDocumentProps.applied, 44 | baseRemoteDocumentProps.name, 45 | baseRemoteDocumentProps.timestamp, 46 | ) 47 | 48 | expect(entity.value).toMatchObject({ 49 | applied: entity.applied, 50 | name: entity.name, 51 | timestamp: entity.timestamp, 52 | }) 53 | }) 54 | 55 | it('should be possible to apply the migration', () => { 56 | const entity = new RemoteMigrationEntity( 57 | baseRemoteDocumentProps.id, 58 | baseRemoteDocumentProps.applied, 59 | baseRemoteDocumentProps.name, 60 | baseRemoteDocumentProps.timestamp, 61 | ) 62 | 63 | entity.apply() 64 | 65 | expect(entity.applied).toBe(true) 66 | }) 67 | 68 | it('should be possible to unapply the migration', () => { 69 | const entity = new RemoteMigrationEntity( 70 | baseRemoteDocumentProps.id, 71 | baseRemoteDocumentProps.applied, 72 | baseRemoteDocumentProps.name, 73 | baseRemoteDocumentProps.timestamp, 74 | ) 75 | 76 | entity.unapply() 77 | 78 | expect(entity.applied).toBe(false) 79 | }) 80 | 81 | describe('create method', () => { 82 | it('should create an instance using the create method', () => { 83 | const props = { ...baseRemoteDocumentProps } 84 | 85 | const entity = RemoteMigrationEntity.create(props) 86 | 87 | expect(entity).toBeInstanceOf(RemoteMigrationEntity) 88 | expect(entity.$id).toBe(props.id) 89 | expect(entity.applied).toBe(props.applied) 90 | expect(entity.name).toBe(props.name) 91 | expect(entity.timestamp).toBe(props.timestamp) 92 | expect(entity.value).toMatchObject({ 93 | applied: props.applied, 94 | name: props.name, 95 | timestamp: props.timestamp, 96 | }) 97 | }) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /src/lib/repositories/entities/RemoteMigrationEntity.ts: -------------------------------------------------------------------------------- 1 | import type { IMigrationEntity } from '@lib/repositories/interfaces' 2 | 3 | /** Represents a migration entity for remote documents. */ 4 | export class RemoteMigrationEntity implements IMigrationEntity { 5 | /** An appwrite document ID */ 6 | #id: string 7 | /** The wether the migration has been applied */ 8 | #applied: boolean 9 | /** An instance of the migration file that matches this entity */ 10 | #instance: undefined 11 | /** The name of the migration (class name) which is also the name in the appwrite document */ 12 | #name: string 13 | /** The timestamp in which the migration was applied */ 14 | #timestamp: number 15 | 16 | public constructor(id: string, applied: boolean, name: string, timestamp: number) { 17 | this.#applied = applied 18 | this.#id = id 19 | this.#instance = undefined 20 | this.#name = name 21 | this.#timestamp = timestamp 22 | } 23 | 24 | static create(props: { 25 | id: string 26 | applied: boolean 27 | name: string 28 | timestamp: number 29 | }) { 30 | return new RemoteMigrationEntity( 31 | props.id, 32 | props.applied, 33 | props.name, 34 | props.timestamp, 35 | ) 36 | } 37 | 38 | public get $id() { 39 | return this.#id 40 | } 41 | 42 | public get applied() { 43 | return this.#applied 44 | } 45 | 46 | public get instance() { 47 | return this.#instance 48 | } 49 | 50 | public get name() { 51 | return this.#name 52 | } 53 | 54 | public get timestamp() { 55 | return this.#timestamp 56 | } 57 | 58 | public get value() { 59 | return { 60 | $id: this.$id, 61 | applied: this.applied, 62 | name: this.name, 63 | timestamp: this.timestamp, 64 | instance: this.instance, 65 | } as const 66 | } 67 | 68 | public apply() { 69 | this.#applied = true 70 | } 71 | 72 | public unapply() { 73 | this.#applied = false 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/lib/repositories/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './LocalMigrationEntity' 2 | export * from './MigrationFileEntity' 3 | export * from './RemoteMigrationEntity' 4 | -------------------------------------------------------------------------------- /src/lib/repositories/index.ts: -------------------------------------------------------------------------------- 1 | export * from './entities' 2 | export * from './interfaces' 3 | export * from './LocalMigrationRepository' 4 | export * from './RemoteMigrationRepository' 5 | -------------------------------------------------------------------------------- /src/lib/repositories/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | import type { DatabaseService } from '@lib/domain' 2 | 3 | import type { Logger } from '../../../index-lib' 4 | 5 | export interface IMigrationEntityValue { 6 | $id?: string | undefined 7 | applied: boolean 8 | instance?: IMigrationFileEntity | undefined 9 | name: string 10 | timestamp: number 11 | } 12 | 13 | export interface IMigrationEntity { 14 | $id?: string 15 | applied?: boolean 16 | instance?: IMigrationFileEntity 17 | name: string 18 | timestamp: number 19 | value: IMigrationEntityValue 20 | apply: () => void 21 | unapply: () => void 22 | } 23 | 24 | export interface IMigrationCommandParams { 25 | db: DatabaseService 26 | log: Logger 27 | error: Logger 28 | } 29 | 30 | export interface IMigrationFileEntity { 31 | /** Applies the migrations. */ 32 | up(params: IMigrationCommandParams): Promise 33 | /** Reverse the migrations. */ 34 | down(params: IMigrationCommandParams): Promise 35 | } 36 | 37 | export type CreateMigrationEntity = Required> 38 | 39 | export type DeleteMigrationEntity = Pick< 40 | Required>, 41 | '$id' 42 | > 43 | 44 | export type UpdateMigrationEntity = Pick< 45 | Required>, 46 | '$id' | 'applied' 47 | > 48 | 49 | export interface IMigrationRepository { 50 | deleteMigration(m: DeleteMigrationEntity): Promise 51 | 52 | insertMigration(m: CreateMigrationEntity): Promise 53 | 54 | listMigrations(): Promise 55 | 56 | updateMigration(m: UpdateMigrationEntity): Promise 57 | } 58 | -------------------------------------------------------------------------------- /src/lib/types/index.ts: -------------------------------------------------------------------------------- 1 | export type Logger = (msg: string) => void 2 | 3 | /** 4 | * Modes in which transactions maybe ran 5 | * 6 | * - each: migration files are executed in a seperate transaction, in sequence 7 | */ 8 | export type TransactionMode = 'each' 9 | -------------------------------------------------------------------------------- /src/lib/utils/createId.spec.ts: -------------------------------------------------------------------------------- 1 | import { createId } from './createId' 2 | 3 | describe('createId', () => { 4 | it('should return a unique nanoid ID with 20 characters', () => { 5 | const result = createId() 6 | 7 | expect(result.length).toBe(20) 8 | }) 9 | 10 | it('should only contain lower case letters or numbers', () => { 11 | const ids: string[] = Array.from({ length: 100 }, () => createId()) 12 | 13 | for (const id of ids) { 14 | expect(id).toMatch(/^[a-z0-9]+$/) 15 | } 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/lib/utils/createId.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from 'nanoid' 2 | 3 | const digits = '1234567890qwertyuiopasdfghjklzxcvbnm' 4 | const customNanoid = customAlphabet(digits) 5 | 6 | /** 7 | * Creates a unique nanoid ID with 20 characters compatible with appwrite ID.unique() 8 | */ 9 | export function createId() { 10 | return customNanoid(20) 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/utils/exponentialBackoff.spec.ts: -------------------------------------------------------------------------------- 1 | import { exponentialBackoff } from './exponentialBackoff' 2 | 3 | describe('exponentialBackoff', () => { 4 | describe('attempt', () => { 5 | it('should raise an error when attempt is less than zero', () => { 6 | expect(() => exponentialBackoff({ interval: 1, rate: 1, attempt: -1 })).toThrow( 7 | TypeError, 8 | ) 9 | }) 10 | }) 11 | 12 | describe('rate', () => { 13 | it('should handle negative rate correctly', () => { 14 | const params = { interval: 100, rate: 1, attempt: 2 } 15 | const expectedBackoff = 100 16 | 17 | const result = exponentialBackoff(params) 18 | 19 | expect(result).toBe(expectedBackoff) 20 | }) 21 | 22 | it('should handle neutral (0) rate correctly', () => { 23 | const params = { interval: 100, rate: 1, attempt: 2 } 24 | const expectedBackoff = 100 25 | 26 | const result = exponentialBackoff(params) 27 | 28 | expect(result).toBe(expectedBackoff) 29 | }) 30 | 31 | it('should handle positive rate correctly', () => { 32 | const params = { interval: 100, rate: 1, attempt: 2 } 33 | const expectedBackoff = 100 34 | 35 | const result = exponentialBackoff(params) 36 | 37 | expect(result).toBe(expectedBackoff) 38 | }) 39 | 40 | it('should handle fractional rate correctly', () => { 41 | const params = { interval: 100, rate: 0.5, attempt: 2 } 42 | const expectedBackoff = 25 43 | 44 | const result = exponentialBackoff(params) 45 | 46 | expect(result).toBe(expectedBackoff) 47 | }) 48 | }) 49 | 50 | test.each<{ input: Parameters[0]; output: number }>([ 51 | { 52 | input: { 53 | interval: 100, 54 | rate: 2, 55 | attempt: 0, 56 | }, 57 | output: 100, 58 | }, 59 | { 60 | input: { 61 | interval: 100, 62 | rate: 2, 63 | attempt: 1, 64 | }, 65 | output: 200, 66 | }, 67 | { 68 | input: { 69 | interval: 100, 70 | rate: 2, 71 | attempt: 2, 72 | }, 73 | output: 400, 74 | }, 75 | { 76 | input: { 77 | interval: 100, 78 | rate: 2, 79 | attempt: 3, 80 | }, 81 | output: 800, 82 | }, 83 | ])('exponentialBackoff($input) should return $output', ({ input, output }) => { 84 | const result = exponentialBackoff(input) 85 | 86 | expect(result).toEqual(output) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /src/lib/utils/exponentialBackoff.ts: -------------------------------------------------------------------------------- 1 | export function exponentialBackoff({ 2 | interval, 3 | rate, 4 | attempt, 5 | }: { 6 | interval: number 7 | rate: number 8 | attempt: number 9 | }): number { 10 | if (attempt < 0) { 11 | throw new TypeError( 12 | `exponentialBackoff 'attempt' to be greater or equal to than zero, but got: ${attempt}`, 13 | ) 14 | } 15 | 16 | return interval * rate ** attempt 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './createId' 2 | export * from './exponentialBackoff' 3 | export * from './poll' 4 | export * from './secondsToMilliseconds' 5 | export * from './sleep' 6 | export * from './type-guards' 7 | -------------------------------------------------------------------------------- /src/lib/utils/poll.spec.ts: -------------------------------------------------------------------------------- 1 | import { poll } from './poll' 2 | import { sleep } from './sleep' 3 | 4 | jest.mock('./sleep.ts', () => ({ 5 | sleep: jest.fn(), 6 | })) 7 | 8 | describe('poll', () => { 9 | const fetcher = jest.fn() 10 | const isReady = jest.fn() 11 | 12 | beforeEach(() => { 13 | jest.clearAllMocks() 14 | }) 15 | 16 | describe('poll', () => { 17 | it('should return the data when isReady condition is met on the first attempt', async () => { 18 | const collectionData = { attributes: ['friendly', 'designation'] } 19 | 20 | fetcher.mockResolvedValue(collectionData) 21 | isReady.mockImplementation((data) => data.attributes.includes('friendly')) 22 | 23 | const result = await poll({ 24 | fetcher, 25 | isReady, 26 | }) 27 | 28 | expect(fetcher).toHaveBeenCalledTimes(1) 29 | expect(isReady).toHaveBeenCalledTimes(1) 30 | expect(isReady).toHaveBeenCalledWith(collectionData) 31 | 32 | expect(result).toEqual([collectionData, null]) 33 | 34 | expect(sleep).not.toHaveBeenCalled() 35 | }) 36 | 37 | it('should return the data when isReady condition is met on the second attempt', async () => { 38 | const firstCollectionData = { attributes: ['designation'] } 39 | const secondCollectionData = { attributes: ['friendly', 'designation'] } 40 | 41 | fetcher 42 | .mockResolvedValueOnce(firstCollectionData) 43 | .mockResolvedValueOnce(secondCollectionData) 44 | 45 | isReady.mockImplementation((data) => data.attributes.includes('friendly')) 46 | 47 | const result = await poll({ 48 | fetcher, 49 | isReady, 50 | }) 51 | 52 | expect(fetcher).toHaveBeenCalledTimes(2) 53 | expect(isReady).toHaveBeenCalledTimes(2) 54 | expect(isReady).toHaveBeenNthCalledWith(1, firstCollectionData) 55 | expect(isReady).toHaveBeenNthCalledWith(2, secondCollectionData) 56 | 57 | expect(result).toEqual([secondCollectionData, null]) 58 | 59 | expect(sleep).toHaveBeenCalledTimes(1) 60 | }) 61 | 62 | it('should return a single error array when all attempts resolve but fail to meet the `isReady` condition', async () => { 63 | const collectionData = { attributes: [] } 64 | 65 | fetcher.mockResolvedValue(collectionData) 66 | isReady.mockReturnValue(false) 67 | 68 | const [data, errors] = await poll({ 69 | fetcher, 70 | isReady, 71 | }) 72 | 73 | expect(fetcher).toHaveBeenCalledTimes(6) 74 | expect(isReady).toHaveBeenCalledTimes(6) 75 | expect(isReady).toHaveBeenNthCalledWith(1, collectionData) 76 | expect(isReady).toHaveBeenNthCalledWith(2, collectionData) 77 | expect(isReady).toHaveBeenNthCalledWith(3, collectionData) 78 | expect(isReady).toHaveBeenNthCalledWith(4, collectionData) 79 | expect(isReady).toHaveBeenNthCalledWith(5, collectionData) 80 | expect(isReady).toHaveBeenNthCalledWith(6, collectionData) 81 | 82 | expect(data).toBeNull() 83 | expect(errors).toBeInstanceOf(Array) 84 | expect(errors).toHaveLength(1) 85 | expect(errors?.[0]).toBeInstanceOf(Error) 86 | 87 | expect(sleep).toHaveBeenCalledTimes(5) 88 | }) 89 | 90 | it('should return a two error array when the fetcher rejects once and subsequent attempts resolve without meeting the `isReady` condition', async () => { 91 | const fetchError = new Error('First fetch exception') 92 | 93 | fetcher.mockRejectedValueOnce(fetchError) 94 | isReady.mockReturnValue(false) 95 | 96 | const [data, errors] = await poll({ 97 | fetcher, 98 | isReady, 99 | }) 100 | 101 | expect(fetcher).toHaveBeenCalledTimes(6) 102 | expect(isReady).toHaveBeenCalledTimes(5) 103 | 104 | expect(data).toBeNull() 105 | expect(errors).toBeInstanceOf(Array) 106 | expect(errors).toHaveLength(2) 107 | expect(errors?.[0]).toEqual(fetchError) 108 | expect(errors?.[1]).toBeInstanceOf(Error) 109 | 110 | expect(sleep).toHaveBeenCalledTimes(5) 111 | }) 112 | 113 | it('should return a three error array when the fetcher rejects twice and subsequent attempts resolve without meeting the `isReady` condition', async () => { 114 | const firstFetchError = new Error('First fetch exception') 115 | const secondFetchError = new Error('Second fetch exception') 116 | 117 | fetcher 118 | .mockRejectedValueOnce(firstFetchError) 119 | .mockRejectedValueOnce(secondFetchError) 120 | .mockResolvedValue({ attributes: [] }) 121 | 122 | isReady.mockReturnValue(false) 123 | 124 | const [data, errors] = await poll({ 125 | fetcher, 126 | isReady, 127 | }) 128 | 129 | expect(fetcher).toHaveBeenCalledTimes(6) 130 | expect(isReady).toHaveBeenCalledTimes(4) 131 | 132 | expect(data).toBeNull() 133 | expect(errors).toBeInstanceOf(Array) 134 | expect(errors).toHaveLength(3) 135 | expect(errors?.[0]).toEqual(firstFetchError) 136 | expect(errors?.[1]).toEqual(secondFetchError) 137 | 138 | expect(sleep).toHaveBeenCalledTimes(5) 139 | }) 140 | 141 | it('should return a three error array when the fetcher rejects twice and other intertwined attempts resolve without meeting the `isReady` condition', async () => { 142 | const firstFetchError = new Error('First fetch exception') 143 | const secondFetchError = new Error('Second fetch exception') 144 | 145 | fetcher 146 | .mockRejectedValueOnce(firstFetchError) 147 | .mockResolvedValueOnce({ attributes: [] }) 148 | .mockRejectedValueOnce(secondFetchError) 149 | .mockResolvedValue({ attributes: [] }) 150 | 151 | isReady.mockReturnValue(false) 152 | 153 | const [data, errors] = await poll({ 154 | fetcher, 155 | isReady, 156 | }) 157 | 158 | expect(fetcher).toHaveBeenCalledTimes(6) 159 | expect(isReady).toHaveBeenCalledTimes(4) 160 | 161 | expect(data).toBeNull() 162 | expect(errors).toBeInstanceOf(Array) 163 | expect(errors).toHaveLength(3) 164 | expect(errors?.[0]).toEqual(firstFetchError) 165 | expect(errors?.[1]).toEqual(secondFetchError) 166 | 167 | expect(sleep).toHaveBeenCalledTimes(5) 168 | }) 169 | }) 170 | }) 171 | -------------------------------------------------------------------------------- /src/lib/utils/poll.ts: -------------------------------------------------------------------------------- 1 | import type { Logger } from '../../index-lib' 2 | import { exponentialBackoff } from './exponentialBackoff' 3 | import { secondsToMilliseconds } from './secondsToMilliseconds' 4 | import { sleep } from './sleep' 5 | 6 | type PolledFetcher = () => Promise 7 | type PolledAsserter = (data: Awaited>>) => boolean 8 | 9 | type PolledData = { 10 | fetcher: PolledFetcher 11 | isReady: PolledAsserter 12 | log?: Logger 13 | error?: Logger 14 | } 15 | 16 | type PolledResult = Promise<[T, null] | [null, Array]> 17 | 18 | export async function poll({ 19 | fetcher, 20 | isReady, 21 | log = () => undefined, 22 | error = () => undefined, 23 | }: PolledData): PolledResult { 24 | const interval = secondsToMilliseconds(5) 25 | const maximumRetries = 5 26 | const maximumAttempts = 1 + maximumRetries 27 | const rate = 2 28 | 29 | let attempt = 0 30 | 31 | const forwardErrors = [] 32 | 33 | while (attempt < maximumAttempts) { 34 | if (attempt > 0) { 35 | await sleep(exponentialBackoff({ interval, rate, attempt })) 36 | } 37 | 38 | try { 39 | const data = await fetcher() 40 | 41 | log(`Poll fetcher resolved. Will evaluate received data: ${JSON.stringify(data)}`) 42 | 43 | if (isReady(data)) { 44 | log('Poll evaluation completed and data is ready for use.') 45 | 46 | return [data, null] 47 | } 48 | log('Poll evaluation completed and but data is not ready for use.') 49 | } catch (e) { 50 | if (e instanceof Error) { 51 | error(`Poll fecher rejected: ${e.message}`) 52 | 53 | forwardErrors.push(e) 54 | } else { 55 | forwardErrors.push( 56 | new Error(`Unexpected exception of type ${typeof e} occured.`), 57 | ) 58 | } 59 | } 60 | 61 | attempt++ 62 | } 63 | 64 | forwardErrors.push( 65 | new Error(`Maximum attempts reached without meeting 'isReady' condition.`), 66 | ) 67 | 68 | return [null, forwardErrors] 69 | } 70 | -------------------------------------------------------------------------------- /src/lib/utils/secondsToMilliseconds.spec.ts: -------------------------------------------------------------------------------- 1 | import { secondsToMilliseconds } from './secondsToMilliseconds' 2 | 3 | describe('secondsToMilliseconds', () => { 4 | it('should convert positive seconds to milliseconds correctly', () => { 5 | const seconds = 5 6 | const expectedMilliseconds = 5000 7 | 8 | const result = secondsToMilliseconds(seconds) 9 | 10 | expect(result).toBe(expectedMilliseconds) 11 | }) 12 | 13 | it('should handle zero seconds correctly', () => { 14 | const seconds = 0 15 | const expectedMilliseconds = 0 16 | 17 | const result = secondsToMilliseconds(seconds) 18 | 19 | expect(result).toBe(expectedMilliseconds) 20 | }) 21 | 22 | it('should convert negative seconds to milliseconds correctly', () => { 23 | const seconds = -3 24 | const expectedMilliseconds = -3000 25 | 26 | const result = secondsToMilliseconds(seconds) 27 | 28 | expect(result).toBe(expectedMilliseconds) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/lib/utils/secondsToMilliseconds.ts: -------------------------------------------------------------------------------- 1 | export function secondsToMilliseconds(value: number) { 2 | return value * 1000 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/utils/sleep.spec.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from './sleep' 2 | 3 | describe('sleep function', () => { 4 | it('should resolve after a specified time', async () => { 5 | const start = Date.now() 6 | const delay = 500 7 | 8 | await sleep(delay) 9 | 10 | const end = Date.now() 11 | const elapsed = end - start 12 | 13 | expect(elapsed).toBeGreaterThanOrEqual(delay - 50) // Account for setTimeout inaccuracy 14 | expect(elapsed).toBeLessThan(delay + 50) // Account for setTimeout inaccuracy 15 | }) 16 | 17 | it('should resolve after a longer specified time', async () => { 18 | const start = Date.now() 19 | const delay = 1000 20 | 21 | await sleep(delay) 22 | 23 | const end = Date.now() 24 | const elapsed = end - start 25 | 26 | expect(elapsed).toBeGreaterThanOrEqual(delay - 50) // Account for setTimeout inaccuracy 27 | expect(elapsed).toBeLessThan(delay + 50) // Account for setTimeout inaccuracy 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/lib/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export function sleep(milliseconds: number) { 2 | return new Promise((resolve) => setTimeout(resolve, milliseconds)) 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/utils/type-guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './isClass' 2 | export * from './isRecord' 3 | -------------------------------------------------------------------------------- /src/lib/utils/type-guards/isClass.spec.ts: -------------------------------------------------------------------------------- 1 | import { isClass } from './isClass' 2 | 3 | class _CustomClass_ {} 4 | 5 | describe('isClass', () => { 6 | it('should return true for a custom class', () => { 7 | expect(isClass(_CustomClass_)).toBe(true) 8 | }) 9 | 10 | it('should return true a native class constructor', () => { 11 | expect(isClass(Date)).toBe(true) 12 | }) 13 | 14 | it('should return false for a non-function value', () => { 15 | expect(isClass({})).toBe(false) 16 | }) 17 | 18 | it('should return false for a function without a prototype', () => { 19 | expect(isClass(() => {})).toBe(false) 20 | }) 21 | 22 | it('should return false for a function without a constructor name', () => { 23 | expect(isClass(() => {})).toBe(false) 24 | }) 25 | 26 | it('should return false for a regular function', () => { 27 | expect(isClass(() => {})).toBe(false) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/lib/utils/type-guards/isClass.ts: -------------------------------------------------------------------------------- 1 | export function isClass( 2 | value: unknown, 3 | ): value is { prototype: { constructor: () => unknown } } { 4 | return ( 5 | typeof value === 'function' && 6 | !!value.prototype && 7 | !!value.prototype.constructor.name 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/utils/type-guards/isRecord.spec.ts: -------------------------------------------------------------------------------- 1 | import { isRecord } from './isRecord' 2 | 3 | describe('isRecord', () => { 4 | it('should return true for an object with string keys and unknown values', () => { 5 | const obj: Record = { 6 | key1: 'value1', 7 | key2: 42, 8 | key3: { nestedKey: 'nestedValue' }, 9 | } 10 | const result = isRecord(obj) 11 | expect(result).toBe(true) 12 | }) 13 | 14 | it('should return false for an array', () => { 15 | const arr = [1, 2, 3] 16 | const result = isRecord(arr) 17 | expect(result).toBe(false) 18 | }) 19 | 20 | it('should return false for null', () => { 21 | const value = null 22 | const result = isRecord(value) 23 | expect(result).toBe(false) 24 | }) 25 | 26 | it('should return false for a string', () => { 27 | const value = 'test' 28 | const result = isRecord(value) 29 | expect(result).toBe(false) 30 | }) 31 | 32 | it('should return false for a number', () => { 33 | const value = 42 34 | const result = isRecord(value) 35 | expect(result).toBe(false) 36 | }) 37 | 38 | it('should return false for undefined', () => { 39 | const value = undefined 40 | const result = isRecord(value) 41 | expect(result).toBe(false) 42 | }) 43 | 44 | it('should return false for a function', () => { 45 | const value = () => {} 46 | const result = isRecord(value) 47 | expect(result).toBe(false) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /src/lib/utils/type-guards/isRecord.ts: -------------------------------------------------------------------------------- 1 | export function isRecord(document: unknown): document is Record { 2 | return !!document && typeof document === 'object' && !Array.isArray(document) 3 | } 4 | -------------------------------------------------------------------------------- /taze.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'taze' 2 | 3 | export default defineConfig({ 4 | exclude: ['change-case', 'nanoid'], 5 | force: true, 6 | includeLocked: false, 7 | install: false, 8 | mode: 'minor', 9 | recursive: true, 10 | sort: 'name-asc', 11 | write: true, 12 | depFields: { 13 | overrides: false, 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /test-utils/deferred.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a promise that can be rejected or resolved on demand 3 | * 4 | * Quick example: 5 | * ```javascript 6 | * test('No state updates happen if the component is unmounted while pending', async () => { 7 | * const {promise, resolve} = deferred() 8 | * const {result, unmount} = renderHook(() => useAsync()) 9 | * let p 10 | * act(() => { p = result.current.run(promise) }) 11 | * unmount() 12 | * await act(async () => { 13 | * resolve() 14 | * await p 15 | * }) 16 | * expect(console.error).not.toHaveBeenCalled() 17 | * }) 18 | * ``` 19 | */ 20 | export function deferred() { 21 | let resolve: (value?: TResolve) => void = () => undefined 22 | let reject: (reason?: TReject) => void = () => undefined 23 | 24 | const promise = new Promise((res, rej) => { 25 | resolve = res 26 | reject = rej 27 | }) 28 | 29 | return { promise, reject, resolve } 30 | } 31 | -------------------------------------------------------------------------------- /test-utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './deferred' 2 | -------------------------------------------------------------------------------- /test-utils/mocks/FakeMigration.ts: -------------------------------------------------------------------------------- 1 | import type { IMigrationCommandParams } from '../../lib' 2 | import { MigrationFileEntity } from '../../src/lib/repositories/entities/MigrationFileEntity' 3 | 4 | export default class Migration_1704463536_FakeMigration extends MigrationFileEntity { 5 | async up(_: IMigrationCommandParams) { 6 | Promise.resolve({ 7 | $id: 'mock-id', 8 | applied: true, 9 | name: 'Migration_1704463536_FakeMigration', 10 | timestamp: Date.now(), 11 | }) 12 | } 13 | 14 | async down(_: IMigrationCommandParams) { 15 | Promise.resolve({ 16 | $id: 'mock-id', 17 | applied: false, 18 | name: 'Migration_1704463536_FakeMigration', 19 | timestamp: Date.now(), 20 | }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/integration.spec.ts: -------------------------------------------------------------------------------- 1 | import { migrationsCreateCollection } from '@lib/migrationsCreateCollection' 2 | import { migrationsCreateDatabase } from '@lib/migrationsCreateDatabase' 3 | import { migrationsRunSequence } from '@lib/migrationsRunSequence' 4 | import { MigrationFileEntity } from '@lib/repositories' 5 | 6 | describe('migrationsCreateCollection', () => { 7 | it('should be defined', () => { 8 | expect(migrationsCreateCollection).toBeDefined() 9 | expect(migrationsCreateCollection).toBeInstanceOf(Function) 10 | }) 11 | }) 12 | 13 | describe('migrationsCreateDatabase', () => { 14 | it('should be defined', () => { 15 | expect(migrationsCreateDatabase).toBeDefined() 16 | expect(migrationsCreateDatabase).toBeInstanceOf(Function) 17 | }) 18 | }) 19 | 20 | describe('migrationsRunSequence', () => { 21 | it('should be defined', () => { 22 | expect(migrationsRunSequence).toBeDefined() 23 | expect(migrationsRunSequence).toBeInstanceOf(Function) 24 | }) 25 | }) 26 | 27 | describe('MigrationFileEntity', () => { 28 | it('should be defined', () => { 29 | expect(MigrationFileEntity).toBeDefined() 30 | expect(MigrationFileEntity).toBeInstanceOf(Function) 31 | expect(MigrationFileEntity.prototype).toBeDefined() 32 | expect(MigrationFileEntity.prototype.constructor).toBeDefined() 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "../", 4 | "paths": { 5 | "@cli": ["../src/cli"], 6 | "@cli/*": ["../src/cli/*"], 7 | "@lib": ["../src/lib"], 8 | "@lib/*": ["../src/lib/*"] 9 | } 10 | }, 11 | "exclude": ["node_modules"], 12 | "include": ["**/*.spec.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /testpkg.sh: -------------------------------------------------------------------------------- 1 | # This file bundles our code and publishes it on our local machine's NPM registry 2 | # Then it attemtps to install it on the test application located in `/app` 3 | # If it works then something like the example below should appear in your dependencies. 4 | # "@owner-or-organization/repository-name": "file:../../../local-npm-registry/owner-or-organization-repository-name-utils-version.tgz" 5 | 6 | LOCAL_NPM_REGISTRY=~/local-npm-registry 7 | 8 | mkdir $LOCAL_NPM_REGISTRY 9 | rm $LOCAL_NPM_REGISTRY/franciscokloganb-*.tgz || true 10 | 11 | npm run build 12 | npm pack --pack-destination $LOCAL_NPM_REGISTRY 13 | 14 | cd app 15 | 16 | npm install $LOCAL_NPM_REGISTRY/franciscokloganb*.tgz 17 | 18 | code ./index.ts 19 | 20 | npm run test:admt 21 | npm run test:lib 22 | 23 | cd .. 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "importHelpers": true, 9 | "lib": ["ESNext"], 10 | "module": "ESNext", 11 | "moduleDetection": "force", 12 | "moduleResolution": "Bundler", 13 | "noEmit": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "noPropertyAccessFromIndexSignature": true, 16 | "resolveJsonModule": true, 17 | "skipLibCheck": true, 18 | "sourceMap": true, 19 | "strict": true, 20 | "target": "ESNext", 21 | "useUnknownInCatchVariables": true, 22 | "paths": { 23 | "@cli": ["./src/cli"], 24 | "@cli/*": ["./src/cli/*"], 25 | "@lib": ["./src/lib"], 26 | "@lib/*": ["./src/lib/*"], 27 | "@test": ["./test"], 28 | "@test/*": ["./test/*"], 29 | "@test-utils": ["./test-utils"], 30 | "@test-utils/*": ["./test-utils/*"] 31 | } 32 | }, 33 | "exclude": ["app", "node_modules"] 34 | } 35 | --------------------------------------------------------------------------------