├── .changeset ├── README.md └── config.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml ├── actions │ └── install-dependencies │ │ └── action.yml ├── dependabot.yml └── workflows │ ├── canary.yml │ ├── changesets.yml │ ├── close-issue.yml │ ├── label-issue.yml │ ├── lock-issue.yml │ ├── pull-request.yml │ └── verify.yml ├── .gitignore ├── .npmrc ├── .scripts ├── formatPackageJson.ts ├── restorePackageJson.ts └── updateVersion.ts ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── biome.json ├── package.json ├── pnpm-lock.yaml ├── src ├── cli.test.ts ├── cli.ts ├── commands │ ├── codegen.test.ts │ ├── codegen.ts │ ├── create.test.ts │ ├── create.ts │ ├── down.test.ts │ ├── down.ts │ ├── init.test.ts │ ├── init.ts │ ├── list.ts │ ├── to.ts │ ├── up.test.ts │ └── up.ts ├── config.test.ts ├── config.ts ├── exports │ ├── index.test.ts │ └── index.ts ├── utils │ ├── clack.ts │ ├── codegen │ │ ├── declarations.ts │ │ ├── definitions │ │ │ ├── mysql.ts │ │ │ ├── postgres.ts │ │ │ └── sqlite.ts │ │ ├── getEnums.ts │ │ ├── getTypes.test.ts │ │ ├── getTypes.ts │ │ └── types.ts │ ├── findConfig.ts │ ├── getAppliedMigrationsCount.ts │ ├── getMigrator.ts │ ├── loadConfig.ts │ ├── loadEnv.test.ts │ ├── loadEnv.ts │ └── logResultSet.ts └── version.ts ├── test ├── config.ts └── migrations │ ├── basic │ ├── 0001_add_user.js │ └── 0002_add_password.js │ ├── mysql │ └── 0001_kitchen_sink.js │ └── postgres │ └── 0001_kitchen_sink.js ├── tsconfig.build.json └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "access": "public", 4 | "baseBranch": "main", 5 | "changelog": [ 6 | "@changesets/changelog-github", 7 | { "repo": "tmm/kysely-migrate" } 8 | ], 9 | "commit": false, 10 | "fixed": [], 11 | "ignore": [], 12 | "linked": [], 13 | "updateInternalDependencies": "patch" 14 | } 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [tmm] -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report bugs or issues. 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: Thanks for taking the time to fill out this bug report! The more info you provide, the more we can help you. 7 | 8 | - type: textarea 9 | attributes: 10 | label: Describe the bug 11 | description: Clear and concise description of what the bug. If you intend to submit a PR for this issue, tell us in the description. Thanks! 12 | placeholder: I am doing… What I expect is… What actually happening is… 13 | validations: 14 | required: true 15 | 16 | - type: input 17 | id: reproduction 18 | attributes: 19 | label: Link to Minimal Reproducible Example 20 | description: Please provide a link to a minimal repository that can reproduce the problem. This makes investigating issues and helping you out significantly easier! For most issues, you will likely get asked to provide a minimal reproducible example so why not add one now :) If a report is vague (e.g. just a generic error message) and has no reproduction, it will receive a "needs reproduction" label. If no reproduction is provided after 3 days, it will be auto-closed. 21 | validations: 22 | required: false 23 | 24 | - type: textarea 25 | attributes: 26 | label: Steps To Reproduce 27 | description: Steps or code snippets to reproduce the behavior. 28 | validations: 29 | required: false 30 | 31 | - type: input 32 | attributes: 33 | label: Package Version 34 | description: What version of kysely-migrate are you using? 35 | placeholder: x.y.z 36 | validations: 37 | required: true 38 | 39 | - type: input 40 | attributes: 41 | label: TypeScript Version 42 | description: What version of TypeScript are you using? kysely-migrate requires `typescript@>=5`. 43 | placeholder: x.y.z 44 | validations: 45 | required: false 46 | 47 | - type: textarea 48 | attributes: 49 | label: Anything else? 50 | description: Anything that will give us more context about the issue you are encountering. (You can attach images or files by clicking this area to highlight and then dragging files in.) 51 | validations: 52 | required: false 53 | 54 | - type: checkboxes 55 | id: checkboxes 56 | attributes: 57 | label: Validations 58 | description: Before submitting this issue, please make sure you do the following. 59 | options: 60 | - label: Checked there isn't [already an issue](https://github.com/tmm/kysely-migrate/issues) that exists for the bug you encountered. 61 | required: true 62 | - label: Added a minimal reproduction. 63 | required: true 64 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Question 4 | url: https://github.com/tmm/kysely-migrate/discussions/new?category=q-a 5 | about: Ask a question and discuss with other community members. 6 | - name: Feature Request 7 | url: https://github.com/tmm/kysely-migrate/discussions/new?category=ideas 8 | about: Request features or brainstorm ideas for new functionality. -------------------------------------------------------------------------------- /.github/actions/install-dependencies/action.yml: -------------------------------------------------------------------------------- 1 | name: "Install dependencies" 2 | description: "Prepare repository and all dependencies" 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - name: Set up pnpm 8 | uses: pnpm/action-setup@v2 9 | 10 | - name: Set up node 11 | uses: actions/setup-node@v3 12 | with: 13 | cache: pnpm 14 | node-version: 18 15 | 16 | - name: Install dependencies 17 | shell: bash 18 | run: pnpm install -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'github-actions' 4 | directory: '/' 5 | schedule: 6 | interval: 'monthly' 7 | -------------------------------------------------------------------------------- /.github/workflows/canary.yml: -------------------------------------------------------------------------------- 1 | name: Canary 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | canary: 7 | name: Release canary 8 | permissions: write-all 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 5 11 | 12 | steps: 13 | - name: Clone repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Install dependencies 17 | uses: ./.github/actions/install-dependencies 18 | 19 | - name: Setup .npmrc file 20 | uses: actions/setup-node@v3 21 | with: 22 | registry-url: 'https://registry.npmjs.org' 23 | 24 | - name: Set version 25 | run: | 26 | npm --no-git-tag-version version 0.0.0 27 | npm --no-git-tag-version version $(npm pkg get version | sed 's/"//g')-canary.$(date +'%Y%m%dT%H%M%S') 28 | pnpm bun version:update 29 | 30 | - name: Build 31 | run: pnpm build 32 | 33 | - name: Publish to npm 34 | run: npm publish --tag canary 35 | env: 36 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/workflows/changesets.yml: -------------------------------------------------------------------------------- 1 | name: Changesets 2 | on: 3 | push: 4 | branches: [main] 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref }} 8 | cancel-in-progress: true 9 | 10 | jobs: 11 | verify: 12 | name: Verify 13 | uses: ./.github/workflows/verify.yml 14 | secrets: inherit 15 | 16 | changesets: 17 | name: Create pull request or publish 18 | needs: verify 19 | permissions: write-all 20 | runs-on: ubuntu-latest 21 | timeout-minutes: 5 22 | 23 | steps: 24 | - name: Clone repository 25 | uses: actions/checkout@v4 26 | with: 27 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 28 | fetch-depth: 0 29 | 30 | - name: Install dependencies 31 | uses: ./.github/actions/install-dependencies 32 | 33 | - name: Create version pull request or publish to npm 34 | uses: changesets/action@v1 35 | with: 36 | title: 'chore: version packages' 37 | commit: 'chore: version packages' 38 | createGithubReleases: ${{ github.ref == 'refs/heads/main' }} 39 | publish: pnpm changeset:publish 40 | version: pnpm changeset:version 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/close-issue.yml: -------------------------------------------------------------------------------- 1 | name: Close Issue 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | jobs: 8 | close-issues: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: needs reproduction 12 | uses: actions-cool/issues-helper@v3 13 | with: 14 | actions: 'close-issues' 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | labels: 'needs reproduction' 17 | inactive-day: 3 18 | -------------------------------------------------------------------------------- /.github/workflows/label-issue.yml: -------------------------------------------------------------------------------- 1 | name: Label Issue 2 | 3 | on: 4 | issues: 5 | types: [labeled] 6 | 7 | jobs: 8 | reply-labeled: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: needs reproduction 12 | if: github.event.label.name == 'needs reproduction' 13 | uses: actions-cool/issues-helper@v3 14 | with: 15 | actions: 'create-comment' 16 | token: ${{ secrets.GITHUB_TOKEN }} 17 | issue-number: ${{ github.event.issue.number }} 18 | body: Hello @${{ github.event.issue.user.login }}. Please provide a [minimal reproduction](https://stackoverflow.com/help/minimal-reproducible-example) using a GitHub repository. Issues marked with "needs reproduction" will be closed if they have no activity within 3 days. 19 | -------------------------------------------------------------------------------- /.github/workflows/lock-issue.yml: -------------------------------------------------------------------------------- 1 | name: Lock Issue 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | permissions: 8 | issues: write 9 | 10 | jobs: 11 | action: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: dessant/lock-threads@v5 15 | with: 16 | github-token: ${{ secrets.GITHUB_TOKEN }} 17 | issue-comment: | 18 | This issue has been locked since it has been closed for more than 14 days. 19 | 20 | If you found a concrete bug or regression related to it, please open a new [bug report](https://github.com/tmm/kysely-migrate/issues/new/choose) with a reproduction against the latest kysely-migrate version. If you have any other comments you can create a new [discussion](https://github.com/tmm/kysely-migrate/discussions). 21 | issue-lock-reason: '' 22 | issue-inactive-days: '14' 23 | process-only: 'issues' 24 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | on: 3 | pull_request: 4 | types: [opened, reopened, synchronize, ready_for_review] 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 8 | cancel-in-progress: true 9 | 10 | jobs: 11 | verify: 12 | name: Verify 13 | uses: ./.github/workflows/verify.yml 14 | secrets: inherit -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | name: Verify 2 | on: 3 | workflow_call: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | lint: 8 | name: Lint 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 5 11 | 12 | steps: 13 | - name: Clone repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Install dependencies 17 | uses: ./.github/actions/install-dependencies 18 | 19 | - name: Lint code 20 | run: pnpm format && pnpm lint:fix 21 | 22 | - name: Update package version 23 | run: pnpm version:update 24 | 25 | - uses: stefanzweifel/git-auto-commit-action@v4 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | with: 29 | commit_message: 'chore: format' 30 | commit_user_name: 'github-actions[bot]' 31 | commit_user_email: 'github-actions[bot]@users.noreply.github.com' 32 | 33 | build: 34 | name: Build 35 | needs: lint 36 | runs-on: ubuntu-latest 37 | timeout-minutes: 5 38 | 39 | steps: 40 | - name: Clone repository 41 | uses: actions/checkout@v4 42 | 43 | - name: Install dependencies 44 | uses: ./.github/actions/install-dependencies 45 | 46 | - name: Build 47 | run: pnpm build 48 | 49 | - name: Publint 50 | run: pnpm test:build 51 | 52 | - name: Check for unused files, dependencies, and exports 53 | run: pnpm knip --production --ignore-internal 54 | 55 | types: 56 | name: Types 57 | needs: lint 58 | runs-on: ubuntu-latest 59 | timeout-minutes: 5 60 | 61 | steps: 62 | - name: Clone repository 63 | uses: actions/checkout@v4 64 | 65 | - name: Install dependencies 66 | uses: ./.github/actions/install-dependencies 67 | 68 | - name: Check types 69 | run: pnpm typecheck 70 | 71 | test: 72 | name: Test 73 | env: 74 | database_host: localhost 75 | database_name: km 76 | database_password: foobarbaz 77 | database_username: dev 78 | runs-on: ubuntu-latest 79 | services: 80 | mysql: 81 | image: mysql:8.0.34 82 | env: 83 | MYSQL_ALLOW_EMPTY_PASSWORD: yes 84 | MYSQL_DATABASE: km 85 | MYSQL_PASSWORD: foobarbaz 86 | MYSQL_USER: dev 87 | options: >- 88 | --health-cmd="mysqladmin ping" 89 | --health-interval=10s 90 | --health-timeout=5s 91 | --health-retries=3 92 | ports: 93 | - 3306:3306 94 | postgres: 95 | image: postgres 96 | env: 97 | POSTGRES_DB: km 98 | POSTGRES_PASSWORD: foobarbaz 99 | POSTGRES_USER: dev 100 | options: >- 101 | --health-cmd pg_isready 102 | --health-interval 10s 103 | --health-timeout 5s 104 | ports: 105 | - 5432:5432 106 | timeout-minutes: 5 107 | 108 | steps: 109 | - name: Clone repository 110 | uses: actions/checkout@v4 111 | 112 | - name: Install dependencies 113 | uses: ./.github/actions/install-dependencies 114 | 115 | - name: Set up databases 116 | run: | 117 | mysql --host 127.0.0.1 --port 3306 -uroot -e 'SET GLOBAL log_bin_trust_function_creators = 1;' 118 | 119 | - name: Run tests 120 | run: pnpm test:cov 121 | 122 | - name: Upload coverage reports to Codecov 123 | uses: codecov/codecov-action@v3 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .pnpm-debug.log* 3 | coverage 4 | dist 5 | node_modules 6 | *.vitest-temp.json 7 | *.tsbuildinfo 8 | test/__app 9 | cli.js 10 | 11 | # local env files 12 | .env 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | .envrc 18 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=false 2 | enable-pre-post-scripts=true 3 | provenance=true 4 | strict-peer-dependencies=false -------------------------------------------------------------------------------- /.scripts/formatPackageJson.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { glob } from 'glob' 3 | 4 | // Generates package.json files to be published to NPM with only the necessary fields. 5 | 6 | console.log('Formatting package.json files.') 7 | 8 | // Get all package.json files 9 | const packagePaths = await glob('**/package.json', { 10 | ignore: ['**/dist/**', '**/node_modules/**'], 11 | }) 12 | 13 | let count = 0 14 | for (const packagePath of packagePaths) { 15 | type Package = Record & { 16 | name?: string | undefined 17 | private?: boolean | undefined 18 | } 19 | const file = Bun.file(packagePath) 20 | const packageJson = (await file.json()) as Package 21 | 22 | // Skip private packages 23 | if (packageJson.private) continue 24 | 25 | count += 1 26 | console.log(`${packageJson.name} — ${path.dirname(packagePath)}`) 27 | 28 | await Bun.write( 29 | `${packagePath}.tmp`, 30 | `${JSON.stringify(packageJson, undefined, 2)}\n`, 31 | ) 32 | 33 | const { 34 | devDependencies: _dD, 35 | knip: _k, 36 | packageManager: _pM, 37 | scripts: _s, 38 | 'simple-git-hooks': _sGH, 39 | ...rest 40 | } = packageJson 41 | await Bun.write(packagePath, `${JSON.stringify(rest, undefined, 2)}\n`) 42 | } 43 | 44 | console.log(`Done. Formatted ${count} ${count === 1 ? 'file' : 'files'}.`) 45 | -------------------------------------------------------------------------------- /.scripts/restorePackageJson.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises' 2 | import path from 'node:path' 3 | import { glob } from 'glob' 4 | 5 | // Restores package.json files from package.json.tmp files. 6 | 7 | console.log('Restoring package.json files.') 8 | 9 | // Get all package.json files 10 | const packagePaths = await glob('**/package.json.tmp', { 11 | ignore: ['**/dist/**', '**/node_modules/**'], 12 | }) 13 | 14 | let count = 0 15 | for (const packagePath of packagePaths) { 16 | type Package = { name?: string | undefined } & Record 17 | const file = Bun.file(packagePath) 18 | const packageJson = (await file.json()) as Package 19 | 20 | count += 1 21 | console.log(`${packageJson.name} — ${path.dirname(packagePath)}`) 22 | 23 | await Bun.write( 24 | packagePath.replace('.tmp', ''), 25 | `${JSON.stringify(packageJson, undefined, 2)}\n`, 26 | ) 27 | await fs.rm(packagePath) 28 | } 29 | 30 | console.log(`Done. Restored ${count} ${count === 1 ? 'file' : 'files'}.`) 31 | -------------------------------------------------------------------------------- /.scripts/updateVersion.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { glob } from 'glob' 3 | 4 | // Updates package version.ts files (so you can use the version in code without importing package.json). 5 | 6 | console.log('Updating version files.') 7 | 8 | // Get all package.json files 9 | const packagePaths = await glob('**/package.json', { 10 | ignore: ['**/dist/**', '**/node_modules/**'], 11 | }) 12 | 13 | let count = 0 14 | for (const packagePath of packagePaths) { 15 | type Package = { 16 | name?: string | undefined 17 | private?: boolean | undefined 18 | version?: string | undefined 19 | } 20 | const file = Bun.file(packagePath) 21 | const packageJson = (await file.json()) as Package 22 | 23 | // Skip private packages 24 | if (packageJson.private) continue 25 | 26 | count += 1 27 | console.log(`${packageJson.name} — ${packageJson.version}`) 28 | 29 | const versionFilePath = path.resolve( 30 | path.dirname(packagePath), 31 | 'src', 32 | 'version.ts', 33 | ) 34 | await Bun.write( 35 | versionFilePath, 36 | `export const version = '${packageJson.version}'\n`, 37 | ) 38 | } 39 | 40 | console.log( 41 | `Done. Updated version file for ${count} ${ 42 | count === 1 ? 'package' : 'packages' 43 | }.`, 44 | ) 45 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "biomejs.biome", 3 | "editor.formatOnSave": true, 4 | "typescript.enablePromptUseWorkspaceTsdk": true, 5 | "typescript.tsdk": "node_modules/typescript/lib", 6 | "editor.codeActionsOnSave": { 7 | "quickfix.biome": true, 8 | "source.organizeImports.biome": true 9 | }, 10 | "[json]": { 11 | "editor.defaultFormatter": "biomejs.biome" 12 | }, 13 | "[javascript]": { 14 | "editor.defaultFormatter": "biomejs.biome" 15 | }, 16 | "[typescript]": { 17 | "editor.defaultFormatter": "biomejs.biome" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # kysely-migrate 2 | 3 | ## 0.0.16 4 | 5 | ### Patch Changes 6 | 7 | - [`bcd05b1`](https://github.com/tmm/kysely-migrate/commit/bcd05b1970fbf851c7c9bbe8bb015328b1bf27ad) Thanks [@tmm](https://github.com/tmm)! - Added provenance 8 | 9 | ## 0.0.15 10 | 11 | ### Patch Changes 12 | 13 | - [`7890bff`](https://github.com/tmm/kysely-migrate/commit/7890bffe6f41f4e33b1eb6796e2494d96eee7e89) Thanks [@tmm](https://github.com/tmm)! - Updated build 14 | 15 | ## 0.0.14 16 | 17 | ### Patch Changes 18 | 19 | - [`05c30b0`](https://github.com/tmm/kysely-migrate/commit/05c30b0cef8a417dd9dc2df27996c29f7e82ee75) Thanks [@tmm](https://github.com/tmm)! - Fixed ESM import issue 20 | 21 | ## 0.0.13 22 | 23 | ### Patch Changes 24 | 25 | - [`410be33`](https://github.com/tmm/kysely-migrate/commit/410be33122dd3446a076621ee5f19926e620ae60) Thanks [@tmm](https://github.com/tmm)! - Updated dialect definitions 26 | 27 | ## 0.0.12 28 | 29 | ### Patch Changes 30 | 31 | - [`059e1b2`](https://github.com/tmm/kysely-migrate/commit/059e1b27377a5e4f267ba95653586d2a588c9f88) Thanks [@tmm](https://github.com/tmm)! - Added enum support 32 | 33 | ## 0.0.11 34 | 35 | ### Patch Changes 36 | 37 | - [`fd98c32`](https://github.com/tmm/kysely-migrate/commit/fd98c32d8e4ead2beb17fcfac332d62e74443a85) Thanks [@tmm](https://github.com/tmm)! - Updated CI logging 38 | 39 | ## 0.0.10 40 | 41 | ### Patch Changes 42 | 43 | - [`a4d261e`](https://github.com/tmm/kysely-migrate/commit/a4d261e2a4cdf92015b9397f9b9894417d641fd5) Thanks [@tmm](https://github.com/tmm)! - Updated exit code for failed migrations 44 | 45 | ## 0.0.9 46 | 47 | ### Patch Changes 48 | 49 | - [`b566b7d`](https://github.com/tmm/kysely-migrate/commit/b566b7d3e431ca02d4254ce765a962ed55908b66) Thanks [@tmm](https://github.com/tmm)! - Updated codegen types 50 | 51 | ## 0.0.8 52 | 53 | ### Patch Changes 54 | 55 | - [`f8e712f`](https://github.com/tmm/kysely-migrate/commit/f8e712f910c3eee5e142c1fd3684fce7b8603da5) Thanks [@tmm](https://github.com/tmm)! - Added wrapper types to codegen 56 | 57 | ## 0.0.7 58 | 59 | ### Patch Changes 60 | 61 | - [`516575b`](https://github.com/tmm/kysely-migrate/commit/516575b781c1aefc6832bc8e6818ec935fa57592) Thanks [@tmm](https://github.com/tmm)! - Switched to interface for DB type 62 | 63 | ## 0.0.6 64 | 65 | ### Patch Changes 66 | 67 | - [`47218b8`](https://github.com/tmm/kysely-migrate/commit/47218b8a7b218cec1863b04e49f2276f984261a0) Thanks [@tmm](https://github.com/tmm)! - Updated DB property value case to match original table name 68 | 69 | ## 0.0.5 70 | 71 | ### Patch Changes 72 | 73 | - [`9474255`](https://github.com/tmm/kysely-migrate/commit/9474255dbf4daa66db8d2cd75c5f070c3d96ce28) Thanks [@tmm](https://github.com/tmm)! - Moved kysely-codegen to peer dependency 74 | 75 | ## 0.0.4 76 | 77 | ### Patch Changes 78 | 79 | - [`a995110`](https://github.com/tmm/kysely-migrate/commit/a995110a77fa5a500e7d760f34d154671265c821) Thanks [@tmm](https://github.com/tmm)! - Added codegen 80 | 81 | ## 0.0.3 82 | 83 | ### Patch Changes 84 | 85 | - [`014f0d4`](https://github.com/tmm/kysely-migrate/commit/014f0d4d09ec60e39f4ad07297a842a76a78039b) Thanks [@tmm](https://github.com/tmm)! - Bumped deps 86 | 87 | ## 0.0.2 88 | 89 | ### Patch Changes 90 | 91 | - [`2267289`](https://github.com/tmm/kysely-migrate/commit/2267289cac5618b572d263d4869f239f751c89f2) Thanks [@tmm](https://github.com/tmm)! - Added README 92 | 93 | ## 0.0.1 94 | 95 | ### Patch Changes 96 | 97 | - [`ae01589`](https://github.com/tmm/kysely-migrate/commit/ae015891b4447f3f4e30fcd2ca0f506f420f56ca) Thanks [@tmm](https://github.com/tmm)! - Initial release 98 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-present Tom Meagher 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kysely-migrate 2 | 3 | [Kysely](https://github.com/kysely-org/kysely) migrations and codegen CLI 4 | 5 | ## Installation 6 | 7 | ```fish 8 | bun add -D kysely-migrate 9 | ``` 10 | 11 | ## Usage 12 | 13 | Create a `kysely-migrate.config.ts` file and fill it out. 14 | 15 | ```ts 16 | import { Kysely, MysqlDialect } from 'kysely' 17 | import { defineConfig } from 'kysely-migrate' 18 | import { createPool } from 'mysql2' 19 | 20 | export default defineConfig({ 21 | db: new Kysely({ 22 | dialect: new MysqlDialect({ pool: createPool('mysql://') }), 23 | }), 24 | migrationFolder: 'src/db/migrations', 25 | codegen: { dialect: 'mysql', out: 'src/db/types.ts' }, 26 | }) 27 | ``` 28 | 29 | Add a `"migrate"` script to your `package.json` file. 30 | 31 | ```json 32 | { 33 | "scripts": { 34 | "migrate": "bun -b kysely-migrate" 35 | } 36 | } 37 | ``` 38 | 39 | Run [commands](#commands) to manage migrations and generate types. 40 | 41 | ```fish 42 | bun migrate [options] 43 | ``` 44 | 45 | ## Commands 46 | 47 | Run `kysely-migrate --help` or `kysely-migrate --help` to see the list of available commands, options, and examples. 48 | 49 | ``` 50 | codegen generate types from database metadata 51 | create create new migration 52 | down migrate one step down 53 | init create configuration file 54 | list list migrations 55 | to migrate to selected migration 56 | up migrate one step up 57 | ``` 58 | 59 | ## API 60 | 61 | ### defineConfig 62 | 63 | Creates [`Config`](#config) object. 64 | 65 | ```ts 66 | import { defineConfig } from 'kysely-migrate' 67 | ``` 68 | 69 | | Name | Type | Description | 70 | | ------- | ------------------------------------------ | --------------------------------------------------------------------- | 71 | | `config` | `Config \| (() => Config \| Promise)` | Configuration object or a function that returns a configuration object. | 72 | | returns | [`Config`](#config) | Configuration object. | 73 | 74 | ### loadEnv 75 | 76 | Loads environment variables from `.env` or `.env.*` files. 77 | 78 | ```ts 79 | import { loadEnv } from 'kysely-migrate' 80 | ``` 81 | 82 | | Name | Type | Description | 83 | | -------------- | ------------------------- | ------------------------------------------- | 84 | | `config.mode` | `string \| undefined` | `.env` file type (e.g. `` `.env.${mode}` ``) | 85 | | `config.envDir` | `string \| undefined` | Directory to load `.env` file from | 86 | | returns | `Record` | Parsed environment variables. | 87 | 88 | ### Config 89 | 90 | `Config` object. 91 | 92 | ```ts 93 | import { type Config } from 'kysely-migrate' 94 | ``` 95 | 96 | ```ts 97 | { 98 | /** Kysely instance used to manipulate migrations and introspect database */ 99 | db: Kysely 100 | /** Path to migrations directory */ 101 | migrationFolder: string 102 | 103 | /** `kysely-migrate codegen` options */ 104 | codegen?: 105 | | { 106 | /** Custom definition mappings for database types to TypeScript types */ 107 | definitions?: Definitions | undefined 108 | /** Dialect definitions to inherit */ 109 | dialect?: 'mysql' | 'postgres' | 'sqlite' | undefined 110 | /** Output file path */ 111 | out: string 112 | } 113 | | undefined 114 | 115 | /** Used for internal `FileMigrationProvider` instance. Defaults to `node:fs/promises`. */ 116 | fs?: FileMigrationProviderFS | undefined 117 | /** Used for internal `FileMigrationProvider` instance. Defaults to `node:path`. */ 118 | path?: FileMigrationProviderPath | undefined 119 | /** Defaults to internal `migrator` created with `db` and `migrationFolder`. */ 120 | migrator?: Migrator | undefined 121 | } 122 | ``` 123 | 124 | ### Dialect Definitions 125 | 126 | Dialect definition files map database types to TypeScript types. They are used by the codegen command to generate types from database metadata. The following dialect definitions are available: 127 | 128 | ```ts 129 | import { 130 | mysqlDefinitions, 131 | postgresDefinitions, 132 | sqliteDefinitions, 133 | } from 'kysely-migrate' 134 | ``` 135 | 136 | ## Frequently Asked Questions 137 | 138 | ### Unknown file extension ".ts" 139 | 140 | If you aren't using Bun, you either need to use the `.js` extension for your migration files or process the TypeScript files yourself. For example, you can use [`tsx`](https://github.com/esbuild-kit/tsx). 141 | 142 | ```json 143 | { 144 | "scripts": { 145 | "migrate": "tsx node_modules/kysely-migrate/dist/esm/cli.js" 146 | } 147 | } 148 | ``` 149 | 150 | ## Contributing 151 | 152 | Contributions to kysely-migrate are greatly appreciated! If you're interested in contributing, please create a [new GitHub Discussion](https://github.com/tmm/kysely-migrate/discussions/new?category=ideas) with some info on what you would like to work on **before submitting a pull request**. 153 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.2.2/schema.json", 3 | "formatter": { 4 | "enabled": true, 5 | "indentStyle": "space", 6 | "indentSize": 2, 7 | "lineWidth": 80 8 | }, 9 | "javascript": { 10 | "formatter": { 11 | "quoteStyle": "single", 12 | "trailingComma": "all", 13 | "semicolons": "asNeeded" 14 | } 15 | }, 16 | "linter": { 17 | "enabled": true, 18 | "rules": { 19 | "recommended": true, 20 | "style": { 21 | "noNonNullAssertion": "off" 22 | }, 23 | "suspicious": { 24 | "noExplicitAny": "off" 25 | } 26 | } 27 | }, 28 | "organizeImports": { 29 | "enabled": true 30 | }, 31 | "vcs": { 32 | "enabled": true, 33 | "clientKind": "git", 34 | "useIgnoreFile": true 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kysely-migrate", 3 | "description": "Kysely migrations and codegen CLI", 4 | "version": "0.0.16", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/tmm/kysely-migrate.git" 9 | }, 10 | "scripts": { 11 | "build": "pnpm clean && pnpm build:esm && pnpm build:types", 12 | "build:esm": "tsc --project tsconfig.build.json --outDir ./dist/esm", 13 | "build:types": "tsc --project tsconfig.build.json --declarationDir ./dist/types --emitDeclarationOnly --declaration --declarationMap", 14 | "changeset:publish": "pnpm version:update && pnpm build && changeset publish", 15 | "changeset:version": "changeset version && pnpm version:update", 16 | "clean": "rm -rf dist *.tsbuildinfo", 17 | "deps": "pnpx taze -r", 18 | "dev": "bun src/cli.ts", 19 | "format": "biome format . --write", 20 | "lint": "biome check .", 21 | "lint:fix": "pnpm lint --apply", 22 | "lint:unused": "pnpm clean && knip --ignore-internal", 23 | "preinstall": "npx only-allow pnpm", 24 | "prepare": "pnpm simple-git-hooks", 25 | "prepublishOnly": "bun .scripts/formatPackageJson.ts", 26 | "test": "vitest", 27 | "test:cov": "vitest run --coverage", 28 | "test:build": "publint --strict", 29 | "typecheck": "tsc --noEmit", 30 | "version:update": "bun .scripts/updateVersion.ts" 31 | }, 32 | "files": [ 33 | "dist/**", 34 | "!dist/**/*.tsbuildinfo", 35 | "src/**/*.ts", 36 | "!src/**/*.test.ts", 37 | "!src/**/*.test-d.ts" 38 | ], 39 | "bin": { 40 | "kysely-migrate": "./dist/esm/cli.js" 41 | }, 42 | "sideEffects": false, 43 | "type": "module", 44 | "main": "./dist/esm/exports/index.js", 45 | "types": "./dist/types/exports/index.d.ts", 46 | "typings": "./dist/types/exports/index.d.ts", 47 | "exports": { 48 | ".": { 49 | "types": "./dist/types/exports/index.d.ts", 50 | "default": "./dist/esm/exports/index.js" 51 | }, 52 | "./package.json": "./package.json" 53 | }, 54 | "peerDependencies": { 55 | "kysely": ">=0.26.3", 56 | "typescript": ">=5" 57 | }, 58 | "dependencies": { 59 | "@clack/prompts": "^0.7.0", 60 | "bundle-require": "^4.0.2", 61 | "cac": "^6.7.14", 62 | "change-case": "^4.1.2", 63 | "dotenv": "^16.3.1", 64 | "dotenv-expand": "^10.0.0", 65 | "esbuild": "0.17.5", 66 | "find-up": "^6.3.0", 67 | "human-id": "^4.1.0", 68 | "is-unicode-supported": "^1.3.0", 69 | "picocolors": "^1.0.0", 70 | "std-env": "^3.4.3" 71 | }, 72 | "devDependencies": { 73 | "@biomejs/biome": "1.1.2", 74 | "@changesets/changelog-github": "0.4.6", 75 | "@changesets/cli": "^2.26.2", 76 | "@types/fs-extra": "^11.0.3", 77 | "@types/node": "^20.8.7", 78 | "@types/pg": "^8.10.7", 79 | "@vitest/coverage-v8": "^0.34.5", 80 | "bun": "1.0.1", 81 | "bun-types": "^1.0.3", 82 | "execa": "^8.0.1", 83 | "fs-extra": "^11.1.1", 84 | "glob": "^10.3.10", 85 | "knip": "^2.29.0", 86 | "kysely": "^0.26.3", 87 | "mysql2": "^3.6.2", 88 | "pg": "^8.11.3", 89 | "publint": "^0.2.2", 90 | "rimraf": "^4.4.1", 91 | "simple-git-hooks": "^2.9.0", 92 | "typescript": "5.2.2", 93 | "vitest": "^0.34.5" 94 | }, 95 | "contributors": ["tmm@awkweb.com"], 96 | "funding": "https://github.com/sponsors/tmm", 97 | "keywords": ["kysely", "cli", "migrate", "migrations", "codegen"], 98 | "packageManager": "pnpm@8.8.0", 99 | "simple-git-hooks": { 100 | "pre-commit": "pnpm format && pnpm lint:fix" 101 | }, 102 | "knip": { 103 | "entry": ["src/**/*.ts!", "src/exports/index.ts!"], 104 | "project": [".scripts/**/*.ts"] 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/cli.test.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'node:path' 2 | import { type ExecaSyncReturnValue, type SyncOptions } from 'execa' 3 | import { execaCommandSync } from 'execa' 4 | import pc from 'picocolors' 5 | import { expect, test } from 'vitest' 6 | 7 | import { version } from './version.js' 8 | 9 | const cliPath = join(__dirname, '../src/cli.ts') 10 | 11 | function run(args: string[], options: SyncOptions = {}): ExecaSyncReturnValue { 12 | return execaCommandSync(`bun ${cliPath} ${args.join(' ')}`, options) 13 | } 14 | 15 | test('--help', () => { 16 | const { stdout } = run(['--help']) 17 | expect( 18 | stdout 19 | .replace(version, 'x.y.z') 20 | .replace(pc.green(''), ''), 21 | ).toMatchInlineSnapshot(` 22 | "kysely-migrate/x.y.z 23 | 24 | Usage: 25 | $ kysely-migrate [options] 26 | 27 | Commands: 28 | codegen generate types from database metadata 29 | create create new migration 30 | down migrate one step down 31 | init create configuration file 32 | list list migrations 33 | to migrate to selected migration 34 | up migrate one step up 35 | 36 | For more info, run any command with the \`--help\` flag: 37 | $ kysely-migrate codegen --help 38 | $ kysely-migrate create --help 39 | $ kysely-migrate down --help 40 | $ kysely-migrate init --help 41 | $ kysely-migrate list --help 42 | $ kysely-migrate to --help 43 | $ kysely-migrate up --help 44 | 45 | Options: 46 | -c, --config Path to config file 47 | -h, --help Display this message 48 | -r, --root Root path to resolve config from 49 | -v, --version Display version number " 50 | `) 51 | }) 52 | 53 | test('--version', () => { 54 | const { stdout } = run(['--version']) 55 | expect(stdout).toContain(`kysely-migrate/${version} `) 56 | }) 57 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { intro, outro } from '@clack/prompts' 3 | import { cac } from 'cac' 4 | import pc from 'picocolors' 5 | 6 | import { type CodegenOptions, codegen } from './commands/codegen.js' 7 | import { type CreateOptions, create } from './commands/create.js' 8 | import { type DownOptions, down } from './commands/down.js' 9 | import { type InitOptions, init } from './commands/init.js' 10 | import { list } from './commands/list.js' 11 | import { type ToOptions, to } from './commands/to.js' 12 | import { type UpOptions, up } from './commands/up.js' 13 | import { findConfig } from './utils/findConfig.js' 14 | import { loadConfig } from './utils/loadConfig.js' 15 | import { version } from './version.js' 16 | 17 | const cli = cac('kysely-migrate') 18 | 19 | cli 20 | .option('-c, --config ', 'Path to config file') 21 | .help() 22 | .option('-r, --root ', 'Root path to resolve config from') 23 | .version(version) 24 | 25 | cli 26 | .command('codegen', 'generate types from database metadata') 27 | .option('-s, --silent', 'Disable output') 28 | .example((name) => `${name} codegen`) 29 | .action(async (options: CliOptions & CodegenOptions) => { 30 | const configPath = await findConfig(options, true) 31 | const config = await loadConfig({ configPath }) 32 | return codegen(config, options) 33 | }) 34 | 35 | cli 36 | .command('create', 'create new migration') 37 | .option('-n, --name ', 'Migration name') 38 | .option('-s, --silent', 'Disable output') 39 | .example((name) => `${name} create`) 40 | .example((name) => `${name} create --name="create_user_table"`) 41 | .action(async (options: CliOptions & CreateOptions) => { 42 | const configPath = await findConfig(options, true) 43 | const config = await loadConfig({ configPath }) 44 | return create(config, options) 45 | }) 46 | 47 | cli 48 | .command('down', 'migrate one step down') 49 | .option('-R, --reset', 'Reset all migrations') 50 | .option('-s, --silent', 'Disable output') 51 | .example((name) => `${name} down`) 52 | .example((name) => `${name} down --reset`) 53 | .action(async (options: CliOptions & DownOptions) => { 54 | const configPath = await findConfig(options, true) 55 | const config = await loadConfig({ configPath }) 56 | return down(config, options) 57 | }) 58 | 59 | cli 60 | .command('init', 'create configuration file') 61 | .option('-s, --silent', 'Disable output') 62 | .example((name) => `${name} init`) 63 | .example((name) => `${name} init --config kysely.config.ts`) 64 | .action(async (options: CliOptions & InitOptions) => { 65 | const configPath = await findConfig(options, true) 66 | const config = await loadConfig({ configPath }) 67 | return init(config, options) 68 | }) 69 | 70 | cli 71 | .command('list', 'list migrations') 72 | .example((name) => `${name} list`) 73 | .action(async (options: CliOptions) => { 74 | const configPath = await findConfig(options, true) 75 | const config = await loadConfig({ configPath }) 76 | return list(config) 77 | }) 78 | 79 | cli 80 | .command('to', 'migrate to selected migration') 81 | .option('-n, --name ', 'Migration name') 82 | .option('-s, --silent', 'Disable output') 83 | .example((name) => `${name} to`) 84 | .example((name) => `${name} to --name 0001_every_mangos_carry`) 85 | .action(async (options: CliOptions & ToOptions) => { 86 | const configPath = await findConfig(options, true) 87 | const config = await loadConfig({ configPath }) 88 | return to(config, options) 89 | }) 90 | 91 | cli 92 | .command('up', 'migrate one step up') 93 | .option('-l, --latest', 'Apply all pending migrations') 94 | .option('-s, --silent', 'Disable output') 95 | .example((name) => `${name} up`) 96 | .example((name) => `${name} up --latest`) 97 | .action(async (options: CliOptions & UpOptions) => { 98 | const configPath = await findConfig(options, true) 99 | const config = await loadConfig({ configPath }) 100 | return up(config, options) 101 | }) 102 | 103 | type CliOptions = { 104 | config?: string | undefined 105 | root?: string | undefined 106 | } 107 | 108 | // Parse CLI args without running command 109 | cli.parse(process.argv, { run: false }) 110 | 111 | if (cli.options.silent) 112 | try { 113 | await cli.runMatchedCommand() 114 | process.exit(0) 115 | } catch { 116 | process.exit(1) 117 | } 118 | else 119 | try { 120 | // If not matched command, either show help or error out 121 | if (!cli.matchedCommand) { 122 | if (cli.args.length === 0) { 123 | if (!cli.options.help && !cli.options.version) cli.outputHelp() 124 | } else { 125 | intro(pc.inverse(' kysely-migrate ')) 126 | throw new Error(`Unknown command: ${cli.args.join(' ')}`) 127 | } 128 | } else { 129 | intro(pc.inverse(' kysely-migrate ')) 130 | } 131 | 132 | const result = await cli.runMatchedCommand() 133 | if (result) outro(result) 134 | process.exit(0) 135 | } catch (error) { 136 | outro(pc.red((error as Error).message)) 137 | process.exit(1) 138 | } 139 | -------------------------------------------------------------------------------- /src/commands/codegen.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import { NO_MIGRATIONS } from 'kysely' 3 | import { expect, test } from 'vitest' 4 | 5 | import { mysqlDb, postgresDb } from '../../test/config.js' 6 | import { type Config } from '../config.js' 7 | import { getMigrator } from '../utils/getMigrator.js' 8 | import { codegen } from './codegen.js' 9 | 10 | test('mysql', async () => { 11 | const config = { 12 | db: mysqlDb, 13 | migrationFolder: 'test/migrations/mysql', 14 | codegen: { 15 | dialect: 'mysql', 16 | out: 'test/__app/types.ts', 17 | }, 18 | } satisfies Config 19 | const migrator = getMigrator(config) 20 | 21 | await migrator.migrateTo(NO_MIGRATIONS) 22 | await migrator.migrateToLatest() 23 | 24 | await codegen(config, { silent: true }) 25 | const file = await fs.readFile(config.codegen.out, 'utf-8') 26 | expect(file).toMatchInlineSnapshot(` 27 | "/** generated by kysely-migrate */ 28 | import { type Generated, type ColumnType, type Selectable, type Insertable, type Updateable } from \\"kysely\\"; 29 | 30 | type UnwrapColumnType = c extends ColumnType ? ColumnType : ColumnType; 31 | 32 | type Json = ColumnType; 33 | 34 | type JsonValue = JsonArray | JsonObject | boolean | null | number | string; 35 | 36 | type JsonArray = JsonValue[]; 37 | 38 | type JsonObject = { 39 | [key in string]?: JsonValue | undefined; 40 | }; 41 | 42 | export type Foo = { 43 | behavior_autoincrementing: Generated; 44 | behavior_default_value: Generated; 45 | behavior_nullable: string | null; 46 | behavior_unwrap_column_type: Generated>>; 47 | field_bigint: number; 48 | field_binary: Buffer; 49 | field_bit: Buffer; 50 | field_blob: Buffer; 51 | field_char: string; 52 | field_date: Date; 53 | field_datetime: Date; 54 | field_decimal: ColumnType; 55 | field_double: number; 56 | field_enum: \\"foo\\" | \\"bar\\" | \\"baz\\"; 57 | field_float: number; 58 | field_int: number; 59 | field_json: Json; 60 | field_longblob: Buffer; 61 | field_longtext: string; 62 | field_mediumblob: Buffer; 63 | field_mediumint: number; 64 | field_mediumtext: string; 65 | field_smallint: number; 66 | field_text: string; 67 | field_time: string; 68 | field_timestamp: Date; 69 | field_tinyblob: Buffer; 70 | field_tinyint: number; 71 | field_varbinary: Buffer; 72 | field_varchar: string; 73 | field_year: number; 74 | }; 75 | 76 | export type FooInsertable = Insertable; 77 | 78 | export type FooSelectable = Selectable; 79 | 80 | export type FooUpdateable = Updateable; 81 | 82 | export interface DB { 83 | foo: Foo; 84 | } 85 | 86 | " 87 | `) 88 | 89 | await migrator.migrateTo(NO_MIGRATIONS) 90 | }) 91 | 92 | test('postgres', async () => { 93 | const config = { 94 | db: postgresDb, 95 | migrationFolder: 'test/migrations/postgres', 96 | codegen: { 97 | dialect: 'postgres', 98 | out: 'test/__app/types.ts', 99 | }, 100 | } satisfies Config 101 | const migrator = getMigrator(config) 102 | 103 | await migrator.migrateTo(NO_MIGRATIONS) 104 | await migrator.migrateToLatest() 105 | 106 | await codegen(config, { silent: true }) 107 | const file = await fs.readFile(config.codegen.out, 'utf-8') 108 | expect(file).toMatchInlineSnapshot(` 109 | "/** generated by kysely-migrate */ 110 | import { type Generated, type ColumnType, type Selectable, type Insertable, type Updateable } from \\"kysely\\"; 111 | 112 | type UnwrapColumnType = c extends ColumnType ? ColumnType : ColumnType; 113 | 114 | type Json = ColumnType; 115 | 116 | type JsonValue = JsonArray | JsonObject | boolean | null | number | string; 117 | 118 | type JsonArray = JsonValue[]; 119 | 120 | type JsonObject = { 121 | [key in string]?: JsonValue | undefined; 122 | }; 123 | 124 | export type Foo = { 125 | behavior_autoincrementing: Generated; 126 | behavior_default_value: Generated; 127 | behavior_nullable: string | null; 128 | behavior_unwrap_column_type: Generated>>; 129 | field_bit: string; 130 | field_bool: boolean; 131 | field_box: string; 132 | field_bpchar: string; 133 | field_bytea: string; 134 | field_cidr: string; 135 | field_date: ColumnType; 136 | field_enum: \\"bar\\" | \\"baz\\" | \\"foo\\"; 137 | field_float4: number; 138 | field_float8: number; 139 | field_inet: string; 140 | field_int2: number; 141 | field_int4: number; 142 | field_int8: ColumnType; 143 | field_json: Json; 144 | field_jsonb: Json; 145 | field_line: string; 146 | field_lseg: string; 147 | field_macaddr: string; 148 | field_money: string; 149 | field_numeric: ColumnType; 150 | field_oid: number; 151 | field_path: string; 152 | field_polygon: string; 153 | field_serial: Generated; 154 | field_text: string; 155 | field_time: string; 156 | field_timestamp: ColumnType; 157 | field_timestamptz: ColumnType; 158 | field_tsquery: string; 159 | field_tsvector: string; 160 | field_txid_snapshot: string; 161 | field_uuid: string; 162 | field_varbit: string; 163 | field_varchar: string; 164 | field_xml: string; 165 | }; 166 | 167 | export type FooInsertable = Insertable; 168 | 169 | export type FooSelectable = Selectable; 170 | 171 | export type FooUpdateable = Updateable; 172 | 173 | export interface DB { 174 | foo: Foo; 175 | } 176 | 177 | " 178 | `) 179 | 180 | await migrator.migrateTo(NO_MIGRATIONS) 181 | }) 182 | -------------------------------------------------------------------------------- /src/commands/codegen.ts: -------------------------------------------------------------------------------- 1 | import { dirname, relative } from 'path' 2 | import { capitalCase } from 'change-case' 3 | import { writeFile } from 'fs/promises' 4 | import pc from 'picocolors' 5 | 6 | import { existsSync } from 'node:fs' 7 | import { mkdir } from 'node:fs/promises' 8 | import { type Config } from '../config.js' 9 | import { S_BAR, S_SUCCESS, message, spinner } from '../utils/clack.js' 10 | import { getEnums } from '../utils/codegen/getEnums.js' 11 | import { getTypes } from '../utils/codegen/getTypes.js' 12 | 13 | export type CodegenOptions = { 14 | silent?: boolean | undefined 15 | } 16 | 17 | export async function codegen(config: Config, options: CodegenOptions = {}) { 18 | if (!config.db) throw new Error('`db` config required to generate types.') 19 | if (!config.codegen) 20 | throw new Error('`codegen` config required to generate types.') 21 | 22 | const typesDir = dirname(config.codegen.out) 23 | if (!existsSync(typesDir)) await mkdir(typesDir) 24 | 25 | const s = spinner(config._spinnerMs, options.silent) 26 | await s.start('Generating types') 27 | 28 | const tables = await config.db.introspection.getTables() 29 | const enums = await getEnums(config.db, config.codegen.dialect) 30 | 31 | const content = getTypes( 32 | tables, 33 | enums, 34 | config.codegen.dialect, 35 | config.codegen.definitions, 36 | ) 37 | await writeFile(config.codegen.out, content) 38 | 39 | s.stop('Generated types') 40 | 41 | if (options.silent) return 42 | 43 | if (tables.length) process.stdout.write(`${pc.gray(S_BAR)}\n`) 44 | 45 | for (const table of tables) { 46 | const count = table.columns.length 47 | const properties = pc.gray( 48 | pc.italic(`${count} ${count === 1 ? 'property' : 'properties'}`), 49 | ) 50 | message( 51 | `${table.name} => ${pc.magenta('export')} ${pc.cyan('type')} ${pc.green( 52 | capitalCase(table.name), 53 | )} = ${pc.yellow('{')} ${properties} ${pc.yellow('}')}`, 54 | { symbol: pc.green(S_SUCCESS) }, 55 | ) 56 | } 57 | 58 | const codegenRelativeFilePath = relative(process.cwd(), config.codegen.out) 59 | return `Created ${pc.green(codegenRelativeFilePath)}` 60 | } 61 | -------------------------------------------------------------------------------- /src/commands/create.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import { afterEach, beforeAll, expect, test } from 'vitest' 3 | 4 | import { mysqlDb } from '../../test/config.js' 5 | import { create } from './create.js' 6 | 7 | const genPath = 'test/__app' 8 | 9 | beforeAll(async () => await fs.ensureDir(genPath)) 10 | 11 | afterEach(() => fs.remove(genPath)) 12 | 13 | test('default', async () => { 14 | const migrationFolder = `${genPath}/create/migrations` 15 | await fs.ensureDir(migrationFolder) 16 | 17 | await create({ db: mysqlDb, migrationFolder }, { silent: true }) 18 | 19 | const generatedFiles = (await fs.readdir(migrationFolder)).sort() 20 | expect(generatedFiles.length).toEqual(1) 21 | }) 22 | 23 | test('name', async () => { 24 | const migrationFolder = `${genPath}/create/migrations` 25 | await fs.ensureDir(migrationFolder) 26 | 27 | await create( 28 | { db: mysqlDb, migrationFolder }, 29 | { 30 | name: 'add user table', 31 | silent: true, 32 | }, 33 | ) 34 | 35 | const generatedFiles = (await fs.readdir(migrationFolder)).sort() 36 | expect(generatedFiles).toMatchInlineSnapshot(` 37 | [ 38 | "0001_add_user_table.ts", 39 | ] 40 | `) 41 | }) 42 | -------------------------------------------------------------------------------- /src/commands/create.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'node:fs' 2 | import { writeFile } from 'node:fs/promises' 3 | import { mkdir } from 'node:fs/promises' 4 | import { relative } from 'node:path' 5 | import { snakeCase } from 'change-case' 6 | import { humanId } from 'human-id' 7 | import pc from 'picocolors' 8 | 9 | import { type Config } from '../config.js' 10 | import { getMigrator } from '../utils/getMigrator.js' 11 | 12 | export type CreateOptions = { 13 | name?: string | undefined 14 | silent?: boolean | undefined 15 | } 16 | 17 | export async function create(config: Config, options: CreateOptions = {}) { 18 | const migrator = getMigrator(config) 19 | 20 | const migrationsDir = config.migrationFolder 21 | if (!existsSync(migrationsDir)) await mkdir(migrationsDir) 22 | 23 | const migrations = await migrator.getMigrations() 24 | const migrationsCount = migrations.length 25 | 26 | const migrationNumber = (migrationsCount + 1).toString().padStart(4, '0') 27 | const migrationName = options.name 28 | ? snakeCase(options.name) 29 | : humanId({ separator: '_', capitalize: false }) 30 | const migrationFileName = `${migrationNumber}_${migrationName}.ts` 31 | const migrationFilePath = `${migrationsDir}/${migrationFileName}` 32 | 33 | const content = `import { type Kysely } from 'kysely' 34 | 35 | export async function up(db: Kysely): Promise {} 36 | 37 | export async function down(db: Kysely): Promise {} 38 | ` 39 | 40 | await writeFile(migrationFilePath, content) 41 | 42 | const migrationRelativeFilePath = relative(process.cwd(), migrationFilePath) 43 | return `Created ${pc.green(migrationRelativeFilePath)}` 44 | } 45 | -------------------------------------------------------------------------------- /src/commands/down.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | 3 | import { mysqlDb } from '../../test/config.js' 4 | import { type Config } from '../config.js' 5 | import { getMigrator } from '../utils/getMigrator.js' 6 | import { down } from './down.js' 7 | 8 | const config = { 9 | db: mysqlDb, 10 | migrationFolder: 'test/migrations/basic', 11 | } satisfies Config 12 | 13 | const migrator = getMigrator(config) 14 | 15 | test('default', async () => { 16 | await migrator.migrateUp() 17 | 18 | let tables = await mysqlDb.introspection.getTables() 19 | expect(tables.length).toBe(1) 20 | 21 | await down(config, { silent: true }) 22 | tables = await mysqlDb.introspection.getTables() 23 | expect(tables.length).toBe(0) 24 | }) 25 | 26 | test('reset', async () => { 27 | await migrator.migrateToLatest() 28 | 29 | let tables = await mysqlDb.introspection.getTables() 30 | expect(tables.length).toBe(2) 31 | 32 | await down(config, { reset: true, silent: true }) 33 | tables = await mysqlDb.introspection.getTables() 34 | expect(tables.length).toBe(0) 35 | }) 36 | -------------------------------------------------------------------------------- /src/commands/down.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'node:fs' 2 | import { mkdir } from 'node:fs/promises' 3 | import { cancel, confirm, isCancel } from '@clack/prompts' 4 | import { type MigrationResultSet, NO_MIGRATIONS } from 'kysely' 5 | 6 | import { type Config } from '../config.js' 7 | import { spinner } from '../utils/clack.js' 8 | import { getAppliedMigrationsCount } from '../utils/getAppliedMigrationsCount.js' 9 | import { getMigrator } from '../utils/getMigrator.js' 10 | import { logResultSet } from '../utils/logResultSet.js' 11 | 12 | export type DownOptions = { 13 | reset?: boolean | undefined 14 | silent?: boolean | undefined 15 | } 16 | 17 | export async function down(config: Config, options: DownOptions = {}) { 18 | const migrator = getMigrator(config) 19 | 20 | const migrationsDir = config.migrationFolder 21 | if (!existsSync(migrationsDir)) await mkdir(migrationsDir) 22 | 23 | const migrations = await migrator.getMigrations() 24 | const executedMigrations = migrations.filter((m) => m.executedAt) 25 | 26 | if (executedMigrations.length === 0) return 'No migrations executed.' 27 | 28 | if (!options.silent && options.reset) { 29 | const shouldContinue = await confirm({ 30 | message: 'Do you want to continue and reset all migrations?', 31 | }) 32 | if (isCancel(shouldContinue)) { 33 | cancel('Operation cancelled') 34 | return process.exit(0) 35 | } 36 | if (!shouldContinue) return 'Applied 0 migrations.' 37 | } 38 | 39 | const s = spinner(config._spinnerMs, options.silent) 40 | await s.start('Running migrations') 41 | 42 | let resultSet: MigrationResultSet 43 | if (options.reset) resultSet = await migrator.migrateTo(NO_MIGRATIONS) 44 | else resultSet = await migrator.migrateDown() 45 | 46 | const { error, results = [] } = resultSet 47 | s.stop('Ran migrations', error ? 1 : 0) 48 | 49 | if (options.silent) return 50 | 51 | logResultSet(resultSet) 52 | 53 | if (error) throw new Error('Failed running migrations.') 54 | return getAppliedMigrationsCount(results) 55 | } 56 | -------------------------------------------------------------------------------- /src/commands/init.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import { afterEach, beforeAll, expect, test } from 'vitest' 3 | 4 | import { mysqlDb } from '../../test/config.js' 5 | import { init } from './init.js' 6 | 7 | const genPath = 'test/__app' 8 | 9 | beforeAll(async () => await fs.ensureDir(genPath)) 10 | 11 | afterEach(() => fs.remove(genPath)) 12 | 13 | test('default', async () => { 14 | await init( 15 | { 16 | db: mysqlDb, 17 | migrationFolder: `${genPath}/migrations`, 18 | }, 19 | { root: genPath, silent: true }, 20 | ) 21 | 22 | const generatedFiles = (await fs.readdir(genPath)).sort() 23 | expect(generatedFiles).toMatchInlineSnapshot(` 24 | [ 25 | "kysely-migrate.config.ts", 26 | ] 27 | `) 28 | }) 29 | -------------------------------------------------------------------------------- /src/commands/init.ts: -------------------------------------------------------------------------------- 1 | import { basename, relative, resolve } from 'node:path' 2 | import { cancel, confirm, isCancel } from '@clack/prompts' 3 | import { writeFile } from 'fs/promises' 4 | import pc from 'picocolors' 5 | 6 | import { type Config, defaultConfig } from '../config.js' 7 | import { findConfig } from '../utils/findConfig.js' 8 | 9 | export type InitOptions = { 10 | config?: string | undefined 11 | root?: string | undefined 12 | silent?: boolean | undefined 13 | } 14 | 15 | export async function init(_config: Config, options: InitOptions) { 16 | const rootDir = resolve(options.root || process.cwd()) 17 | const outPath = resolve(rootDir, 'kysely-migrate.config.ts') 18 | 19 | const content = `import { defineConfig } from 'kysely-migrate' 20 | 21 | export default defineConfig(${JSON.stringify(defaultConfig)}) 22 | ` 23 | 24 | // Check for existing config file 25 | const configPath = await findConfig(options) 26 | let shouldContinue: boolean | symbol = true 27 | if (configPath && basename(configPath) === basename(outPath)) { 28 | if (options.silent) throw new Error('Config already exists.') 29 | 30 | shouldContinue = await confirm({ 31 | message: `Overwrite config file at ${pc.gray( 32 | relative(process.cwd(), configPath), 33 | )}?`, 34 | }) 35 | 36 | if (isCancel(shouldContinue)) { 37 | cancel('Operation cancelled') 38 | return process.exit(0) 39 | } 40 | } 41 | 42 | if (shouldContinue) { 43 | await writeFile(outPath, content) 44 | return `Created config file at ${pc.green( 45 | relative(process.cwd(), outPath), 46 | )}` 47 | } 48 | 49 | return 'Config file not created.' 50 | } 51 | -------------------------------------------------------------------------------- /src/commands/list.ts: -------------------------------------------------------------------------------- 1 | import pc from 'picocolors' 2 | 3 | import type { Config } from '../config.js' 4 | import { S_BAR, S_INFO, S_SUCCESS, message } from '../utils/clack.js' 5 | import { getMigrator } from '../utils/getMigrator.js' 6 | 7 | export async function list(config: Config) { 8 | const migrator = getMigrator(config) 9 | 10 | const migrations = await migrator.getMigrations() 11 | const migrationsCount = migrations.length 12 | 13 | process.stdout.write(`${pc.gray(S_BAR)}\n`) 14 | 15 | for (const migration of migrations) { 16 | if (migration.executedAt) 17 | message( 18 | `${migration.name} ${pc.dim(migration.executedAt.toISOString())}`, 19 | { symbol: pc.green(S_SUCCESS) }, 20 | ) 21 | else message(migration.name, { symbol: pc.blue(S_INFO) }) 22 | } 23 | 24 | return `Found ${migrationsCount} ${ 25 | migrationsCount === 1 ? 'migration' : 'migrations' 26 | }.` 27 | } 28 | -------------------------------------------------------------------------------- /src/commands/to.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'node:fs' 2 | import { mkdir } from 'node:fs/promises' 3 | import { cancel, isCancel, select } from '@clack/prompts' 4 | import pc from 'picocolors' 5 | 6 | import { type Config } from '../config.js' 7 | import { spinner } from '../utils/clack.js' 8 | import { getAppliedMigrationsCount } from '../utils/getAppliedMigrationsCount.js' 9 | import { getMigrator } from '../utils/getMigrator.js' 10 | import { logResultSet } from '../utils/logResultSet.js' 11 | 12 | export type ToOptions = { 13 | name?: string | undefined 14 | silent?: boolean | undefined 15 | } 16 | 17 | export async function to(config: Config, options: ToOptions) { 18 | const migrator = getMigrator(config) 19 | 20 | const migrationsDir = config.migrationFolder 21 | if (!existsSync(migrationsDir)) await mkdir(migrationsDir) 22 | 23 | const migrations = await migrator.getMigrations() 24 | 25 | if (migrations.length === 0) throw new Error('No migrations.') 26 | if (migrations.length === 1) 27 | throw new Error('Must have more than one migration.') 28 | 29 | let migration: string | symbol 30 | if (options.name) migration = options.name 31 | else { 32 | if (options.silent) throw new Error('--name required when using --silent.') 33 | 34 | const lastExecutedMigration = migrations 35 | .filter((migration) => migration.executedAt) 36 | .at(-1) 37 | const lastExecutedMigrationIndex = lastExecutedMigration 38 | ? migrations.findIndex( 39 | (migration) => migration.name === lastExecutedMigration.name, 40 | ) 41 | : -1 42 | 43 | migration = await select({ 44 | message: `Pick a migration to target.${ 45 | lastExecutedMigration 46 | ? pc.gray(` Current: ${lastExecutedMigration.name}`) 47 | : '' 48 | }`, 49 | options: migrations 50 | .map((migration, index) => ({ 51 | label: `${migration.name}${ 52 | lastExecutedMigrationIndex > index ? '.down' : '.up' 53 | }`, 54 | value: migration.name, 55 | ...(migration.executedAt 56 | ? { hint: migration.executedAt.toISOString() } 57 | : {}), 58 | })) 59 | .filter((option) => option.value !== lastExecutedMigration?.name), 60 | }) 61 | 62 | if (isCancel(migration)) { 63 | cancel('Operation cancelled') 64 | return process.exit(0) 65 | } 66 | } 67 | 68 | const s = spinner(config._spinnerMs, options.silent) 69 | await s.start('Running migrations') 70 | 71 | const resultSet = await migrator.migrateTo(migration) 72 | 73 | const { error, results = [] } = resultSet 74 | s.stop('Ran migrations', error ? 1 : 0) 75 | 76 | if (options.silent) return 77 | 78 | logResultSet(resultSet) 79 | 80 | if (error) throw new Error('Failed running migrations.') 81 | return getAppliedMigrationsCount(results) 82 | } 83 | -------------------------------------------------------------------------------- /src/commands/up.test.ts: -------------------------------------------------------------------------------- 1 | import { NO_MIGRATIONS } from 'kysely' 2 | import { expect, test } from 'vitest' 3 | 4 | import { mysqlDb } from '../../test/config.js' 5 | import { type Config } from '../config.js' 6 | import { getMigrator } from '../utils/getMigrator.js' 7 | import { up } from './up.js' 8 | 9 | const config = { 10 | db: mysqlDb, 11 | migrationFolder: 'test/migrations/basic', 12 | } satisfies Config 13 | 14 | const migrator = getMigrator(config) 15 | 16 | test('default', async () => { 17 | await migrator.migrateTo(NO_MIGRATIONS) 18 | 19 | await up(config, { silent: true }) 20 | 21 | const tables = await mysqlDb.introspection.getTables() 22 | expect(tables.length).toBe(1) 23 | expect(tables).toMatchInlineSnapshot(` 24 | [ 25 | { 26 | "columns": [ 27 | { 28 | "dataType": "int", 29 | "hasDefaultValue": false, 30 | "isAutoIncrementing": true, 31 | "isNullable": false, 32 | "name": "id", 33 | }, 34 | { 35 | "dataType": "timestamp", 36 | "hasDefaultValue": true, 37 | "isAutoIncrementing": false, 38 | "isNullable": false, 39 | "name": "created_at", 40 | }, 41 | { 42 | "dataType": "varchar", 43 | "hasDefaultValue": false, 44 | "isAutoIncrementing": false, 45 | "isNullable": false, 46 | "name": "email", 47 | }, 48 | { 49 | "dataType": "timestamp", 50 | "hasDefaultValue": true, 51 | "isAutoIncrementing": false, 52 | "isNullable": false, 53 | "name": "updated_at", 54 | }, 55 | ], 56 | "isView": false, 57 | "name": "user", 58 | "schema": "km", 59 | }, 60 | ] 61 | `) 62 | 63 | await migrator.migrateTo(NO_MIGRATIONS) 64 | }) 65 | 66 | test('latest', async () => { 67 | await migrator.migrateTo(NO_MIGRATIONS) 68 | 69 | await up(config, { latest: true, silent: true }) 70 | const tables = await mysqlDb.introspection.getTables() 71 | expect(tables.length).toBe(2) 72 | expect(tables).toMatchInlineSnapshot(` 73 | [ 74 | { 75 | "columns": [ 76 | { 77 | "dataType": "int", 78 | "hasDefaultValue": false, 79 | "isAutoIncrementing": true, 80 | "isNullable": false, 81 | "name": "id", 82 | }, 83 | { 84 | "dataType": "varchar", 85 | "hasDefaultValue": false, 86 | "isAutoIncrementing": false, 87 | "isNullable": false, 88 | "name": "hash", 89 | }, 90 | { 91 | "dataType": "int", 92 | "hasDefaultValue": false, 93 | "isAutoIncrementing": false, 94 | "isNullable": false, 95 | "name": "user_id", 96 | }, 97 | ], 98 | "isView": false, 99 | "name": "password", 100 | "schema": "km", 101 | }, 102 | { 103 | "columns": [ 104 | { 105 | "dataType": "int", 106 | "hasDefaultValue": false, 107 | "isAutoIncrementing": true, 108 | "isNullable": false, 109 | "name": "id", 110 | }, 111 | { 112 | "dataType": "timestamp", 113 | "hasDefaultValue": true, 114 | "isAutoIncrementing": false, 115 | "isNullable": false, 116 | "name": "created_at", 117 | }, 118 | { 119 | "dataType": "varchar", 120 | "hasDefaultValue": false, 121 | "isAutoIncrementing": false, 122 | "isNullable": false, 123 | "name": "email", 124 | }, 125 | { 126 | "dataType": "timestamp", 127 | "hasDefaultValue": true, 128 | "isAutoIncrementing": false, 129 | "isNullable": false, 130 | "name": "updated_at", 131 | }, 132 | ], 133 | "isView": false, 134 | "name": "user", 135 | "schema": "km", 136 | }, 137 | ] 138 | `) 139 | 140 | await migrator.migrateTo(NO_MIGRATIONS) 141 | }) 142 | -------------------------------------------------------------------------------- /src/commands/up.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'node:fs' 2 | import { mkdir } from 'node:fs/promises' 3 | import { type MigrationResultSet } from 'kysely' 4 | 5 | import { type Config } from '../config.js' 6 | import { spinner } from '../utils/clack.js' 7 | import { getAppliedMigrationsCount } from '../utils/getAppliedMigrationsCount.js' 8 | import { getMigrator } from '../utils/getMigrator.js' 9 | import { logResultSet } from '../utils/logResultSet.js' 10 | 11 | export type UpOptions = { 12 | latest?: boolean | undefined 13 | silent?: boolean | undefined 14 | } 15 | 16 | export async function up(config: Config, options: UpOptions = {}) { 17 | const migrator = getMigrator(config) 18 | 19 | const migrationsDir = config.migrationFolder 20 | if (!existsSync(migrationsDir)) await mkdir(migrationsDir) 21 | 22 | const migrations = await migrator.getMigrations() 23 | const pendingMigrations = migrations.filter((m) => !m.executedAt) 24 | 25 | if (pendingMigrations.length === 0) return 'No pending migrations.' 26 | 27 | const s = spinner(config._spinnerMs, options.silent) 28 | await s.start('Running migrations') 29 | 30 | let resultSet: MigrationResultSet 31 | if (options.latest) resultSet = await migrator.migrateToLatest() 32 | else resultSet = await migrator.migrateUp() 33 | 34 | const { error, results = [] } = resultSet 35 | s.stop('Ran migrations', error ? 1 : 0) 36 | 37 | if (options.silent) return 38 | 39 | logResultSet(resultSet) 40 | 41 | if (error) throw new Error('Failed running migrations.') 42 | return getAppliedMigrationsCount(results) 43 | } 44 | -------------------------------------------------------------------------------- /src/config.test.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, MysqlDialect } from 'kysely' 2 | import { createPool } from 'mysql2' 3 | import { expect, test } from 'vitest' 4 | 5 | import { defineConfig } from './config.js' 6 | 7 | test('defineConfig', () => { 8 | expect( 9 | defineConfig({ 10 | db: new Kysely({ 11 | dialect: new MysqlDialect({ pool: createPool('mysql://') }), 12 | }), 13 | migrationFolder: 'src/db/migrations', 14 | codegen: { 15 | dialect: 'mysql', 16 | out: 'src/db/types.ts', 17 | }, 18 | }), 19 | ).toBeDefined() 20 | }) 21 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type FileMigrationProviderFS, 3 | type FileMigrationProviderPath, 4 | type Kysely, 5 | Migrator, 6 | } from 'kysely' 7 | 8 | import { type Definitions } from './utils/codegen/types.js' 9 | 10 | export type Config = { 11 | codegen?: Evaluate | undefined 12 | db: Kysely 13 | fs?: FileMigrationProviderFS | undefined 14 | path?: FileMigrationProviderPath | undefined 15 | migrationFolder: string 16 | migrator?: Migrator | undefined 17 | _spinnerMs?: number | undefined 18 | } 19 | 20 | type Codegen = 21 | | { 22 | definitions?: Evaluate | undefined 23 | dialect: 'mysql' | 'postgres' | 'sqlite' 24 | out: string 25 | } 26 | | { 27 | definitions: Evaluate | undefined 28 | dialect?: 'mysql' | 'postgres' | 'sqlite' | undefined 29 | out: string 30 | } 31 | 32 | export function defineConfig( 33 | config: 34 | | Evaluate 35 | | (() => Evaluate | Promise>), 36 | ) { 37 | return config 38 | } 39 | 40 | export const defaultConfig = {} 41 | 42 | type Evaluate = { [key in keyof type]: type[key] } & unknown 43 | -------------------------------------------------------------------------------- /src/exports/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | 3 | import * as Exports from './index.js' 4 | 5 | test('exports', () => { 6 | expect(Object.keys(Exports)).toMatchInlineSnapshot(` 7 | [ 8 | "defineConfig", 9 | "loadEnv", 10 | "mysqlDefinitions", 11 | "postgresDefinitions", 12 | "sqliteDefinitions", 13 | "version", 14 | ] 15 | `) 16 | }) 17 | -------------------------------------------------------------------------------- /src/exports/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | defineConfig, 3 | type Config, 4 | } from '../config.js' 5 | 6 | export { loadEnv } from '../utils/loadEnv.js' 7 | 8 | export { mysqlDefinitions } from '../utils/codegen/definitions/mysql.js' 9 | export { postgresDefinitions } from '../utils/codegen/definitions/postgres.js' 10 | export { sqliteDefinitions } from '../utils/codegen/definitions/sqlite.js' 11 | 12 | export { version } from '../version.js' 13 | -------------------------------------------------------------------------------- /src/utils/clack.ts: -------------------------------------------------------------------------------- 1 | import { setTimeout as sleep } from 'node:timers/promises' 2 | import { 3 | type LogMessageOptions, 4 | spinner as clack_spinner, 5 | } from '@clack/prompts' 6 | import isUnicodeSupported from 'is-unicode-supported' 7 | import pc from 'picocolors' 8 | import { isCI } from 'std-env' 9 | 10 | // TODO: Import from Clack 11 | //////////////////////////////////////////////////////////////////////////////////////////////// 12 | const unicode = isUnicodeSupported() 13 | 14 | function s(c: string, fallback: string) { 15 | if (unicode) return c 16 | return fallback 17 | } 18 | 19 | export const S_BAR = s('│', '|') 20 | export const S_ERROR = s('■', 'x') 21 | export const S_INFO = s('●', '•') 22 | export const S_SUCCESS = s('◆', '*') 23 | //////////////////////////////////////////////////////////////////////////////////////////////// 24 | 25 | export function message(message = '', options: LogMessageOptions = {}) { 26 | const { symbol = pc.gray(S_BAR) } = options 27 | const parts = [] 28 | if (message) { 29 | const [firstLine, ...lines] = message.split('\n') 30 | parts.push( 31 | `${symbol} ${firstLine}`, 32 | ...lines.map((ln) => `${pc.gray(S_BAR)} ${ln}`), 33 | ) 34 | } 35 | process.stdout.write(`${parts.join('\n')}\n`) 36 | } 37 | 38 | // TODO: CI check should be handled by Clack 39 | // https://github.com/natemoo-re/clack/pull/169 40 | export function spinner(ms = 250, silent = false) { 41 | const spin = clack_spinner() 42 | return { 43 | async start(msg: string) { 44 | if (silent) return 45 | 46 | if (isCI) message(msg, { symbol: pc.green(s('◇', 'o')) }) 47 | else { 48 | spin.start(msg) 49 | // so spinner has a chance :) 50 | if (ms) await sleep(ms) 51 | } 52 | }, 53 | stop(msg: string, error: unknown = undefined) { 54 | if (silent) return 55 | 56 | if (isCI) { 57 | if (error) message(msg, { symbol: pc.red(S_ERROR) }) 58 | else message(msg, { symbol: pc.green(S_SUCCESS) }) 59 | } else spin.stop(msg, error ? 1 : 0) 60 | }, 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/utils/codegen/declarations.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript' 2 | 3 | export const kyselyColumnTypeIdentifier = 4 | ts.factory.createIdentifier('ColumnType') 5 | export const kyselyColumnTypeImportSpecifier = ts.factory.createImportSpecifier( 6 | true, 7 | undefined, 8 | kyselyColumnTypeIdentifier, 9 | ) 10 | 11 | export const kyselyGeneratedIdentifier = 12 | ts.factory.createIdentifier('Generated') 13 | export const kyselyGeneratedImportSpecifier = ts.factory.createImportSpecifier( 14 | true, 15 | undefined, 16 | kyselyGeneratedIdentifier, 17 | ) 18 | 19 | export const kyselyInsertableIdentifier = 20 | ts.factory.createIdentifier('Insertable') 21 | export const kyselyInsertableImportSpecifier = ts.factory.createImportSpecifier( 22 | true, 23 | undefined, 24 | kyselyInsertableIdentifier, 25 | ) 26 | 27 | export const kyselySelectableIdentifier = 28 | ts.factory.createIdentifier('Selectable') 29 | export const kyselySelectableImportSpecifier = ts.factory.createImportSpecifier( 30 | true, 31 | undefined, 32 | kyselySelectableIdentifier, 33 | ) 34 | 35 | export const kyselyUpdateableIdentifier = 36 | ts.factory.createIdentifier('Updateable') 37 | export const kyselyUpdateableImportSpecifier = ts.factory.createImportSpecifier( 38 | true, 39 | undefined, 40 | kyselyUpdateableIdentifier, 41 | ) 42 | 43 | export const jsonIdentifier = ts.factory.createIdentifier('Json') 44 | export const jsonValueIdentifier = ts.factory.createIdentifier('JsonValue') 45 | export const jsonArrayIdentifier = ts.factory.createIdentifier('JsonArray') 46 | export const jsonObjectIndentifier = ts.factory.createIdentifier('JsonObject') 47 | 48 | export const jsonTypeAlias = ts.factory.createTypeAliasDeclaration( 49 | [], 50 | jsonIdentifier, 51 | undefined, 52 | ts.factory.createTypeReferenceNode(kyselyColumnTypeIdentifier, [ 53 | ts.factory.createTypeReferenceNode(jsonValueIdentifier, undefined), 54 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 55 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 56 | ]), 57 | ) 58 | 59 | export const jsonValueTypeAlias = ts.factory.createTypeAliasDeclaration( 60 | [], 61 | jsonValueIdentifier, 62 | undefined, 63 | ts.factory.createUnionTypeNode([ 64 | ts.factory.createTypeReferenceNode(jsonArrayIdentifier, undefined), 65 | ts.factory.createTypeReferenceNode(jsonObjectIndentifier, undefined), 66 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword), 67 | ts.factory.createLiteralTypeNode(ts.factory.createNull()), 68 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 69 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 70 | ]), 71 | ) 72 | 73 | export const jsonArrayTypeAlias = ts.factory.createTypeAliasDeclaration( 74 | [], 75 | jsonArrayIdentifier, 76 | undefined, 77 | ts.factory.createArrayTypeNode( 78 | ts.factory.createTypeReferenceNode(jsonValueIdentifier, undefined), 79 | ), 80 | ) 81 | 82 | export const jsonObjectTypeAlias = ts.factory.createTypeAliasDeclaration( 83 | [], 84 | jsonObjectIndentifier, 85 | undefined, 86 | ts.factory.createMappedTypeNode( 87 | undefined, 88 | ts.factory.createTypeParameterDeclaration( 89 | undefined, 90 | ts.factory.createIdentifier('key'), 91 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 92 | undefined, 93 | ), 94 | undefined, 95 | ts.factory.createToken(ts.SyntaxKind.QuestionToken), 96 | ts.factory.createUnionTypeNode([ 97 | ts.factory.createTypeReferenceNode(jsonValueIdentifier, undefined), 98 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword), 99 | ]), 100 | undefined, 101 | ), 102 | ) 103 | 104 | const columnIdentifier = ts.factory.createIdentifier('c') 105 | const selectIdentifier = ts.factory.createIdentifier('s') 106 | const insertIdentifier = ts.factory.createIdentifier('i') 107 | const updateIdentifier = ts.factory.createIdentifier('u') 108 | 109 | export const unwrapColumnTypeIdentifier = 110 | ts.factory.createIdentifier('UnwrapColumnType') 111 | export const unwrapColumnTypeTypeAlias = ts.factory.createTypeAliasDeclaration( 112 | [], 113 | unwrapColumnTypeIdentifier, 114 | [ 115 | ts.factory.createTypeParameterDeclaration( 116 | undefined, 117 | columnIdentifier, 118 | undefined, 119 | undefined, 120 | ), 121 | ], 122 | ts.factory.createConditionalTypeNode( 123 | ts.factory.createTypeReferenceNode(columnIdentifier, undefined), 124 | ts.factory.createTypeReferenceNode(kyselyColumnTypeIdentifier, [ 125 | ts.factory.createInferTypeNode( 126 | ts.factory.createTypeParameterDeclaration( 127 | undefined, 128 | selectIdentifier, 129 | undefined, 130 | undefined, 131 | ), 132 | ), 133 | ts.factory.createInferTypeNode( 134 | ts.factory.createTypeParameterDeclaration( 135 | undefined, 136 | insertIdentifier, 137 | undefined, 138 | undefined, 139 | ), 140 | ), 141 | ts.factory.createInferTypeNode( 142 | ts.factory.createTypeParameterDeclaration( 143 | undefined, 144 | updateIdentifier, 145 | undefined, 146 | undefined, 147 | ), 148 | ), 149 | ]), 150 | ts.factory.createTypeReferenceNode(kyselyColumnTypeIdentifier, [ 151 | ts.factory.createTypeReferenceNode(selectIdentifier, undefined), 152 | ts.factory.createUnionTypeNode([ 153 | ts.factory.createTypeReferenceNode(insertIdentifier, undefined), 154 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword), 155 | ]), 156 | ts.factory.createTypeReferenceNode(updateIdentifier, undefined), 157 | ]), 158 | ts.factory.createTypeReferenceNode(kyselyColumnTypeIdentifier, [ 159 | ts.factory.createTypeReferenceNode(columnIdentifier, undefined), 160 | ts.factory.createUnionTypeNode([ 161 | ts.factory.createTypeReferenceNode(columnIdentifier, undefined), 162 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword), 163 | ]), 164 | ts.factory.createTypeReferenceNode(columnIdentifier, undefined), 165 | ]), 166 | ), 167 | ) 168 | -------------------------------------------------------------------------------- /src/utils/codegen/definitions/mysql.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript' 2 | 3 | import { 4 | jsonArrayTypeAlias, 5 | jsonIdentifier, 6 | jsonObjectTypeAlias, 7 | jsonTypeAlias, 8 | jsonValueTypeAlias, 9 | kyselyColumnTypeIdentifier, 10 | kyselyColumnTypeImportSpecifier, 11 | } from '../declarations.js' 12 | import { type Definitions } from '../types.js' 13 | 14 | export const mysqlDefinitions = { 15 | bigint: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 16 | binary: ts.factory.createTypeReferenceNode( 17 | ts.factory.createIdentifier('Buffer'), 18 | undefined, 19 | ), 20 | bit: ts.factory.createTypeReferenceNode( 21 | ts.factory.createIdentifier('Buffer'), 22 | undefined, 23 | ), 24 | blob: ts.factory.createTypeReferenceNode( 25 | ts.factory.createIdentifier('Buffer'), 26 | undefined, 27 | ), 28 | char: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 29 | date: ts.factory.createTypeReferenceNode( 30 | ts.factory.createIdentifier('Date'), 31 | undefined, 32 | ), 33 | datetime: ts.factory.createTypeReferenceNode( 34 | ts.factory.createIdentifier('Date'), 35 | undefined, 36 | ), 37 | decimal: { 38 | imports: { kysely: [kyselyColumnTypeImportSpecifier] }, 39 | declarations: [], 40 | value: ts.factory.createTypeReferenceNode(kyselyColumnTypeIdentifier, [ 41 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 42 | ts.factory.createUnionTypeNode([ 43 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 44 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 45 | ]), 46 | ts.factory.createUnionTypeNode([ 47 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 48 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 49 | ]), 50 | ]), 51 | }, 52 | double: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 53 | enum(column, table, enums) { 54 | const values = enums.get(`${table.schema}.${table.name}.${column.name}`) 55 | if (!values) 56 | return ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword) 57 | return ts.factory.createUnionTypeNode( 58 | values.map((value) => 59 | ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(value)), 60 | ), 61 | ) 62 | }, 63 | float: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 64 | int: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 65 | json: { 66 | imports: { kysely: [kyselyColumnTypeImportSpecifier] }, 67 | declarations: [ 68 | jsonTypeAlias, 69 | jsonValueTypeAlias, 70 | jsonArrayTypeAlias, 71 | jsonObjectTypeAlias, 72 | ], 73 | value: ts.factory.createTypeReferenceNode(jsonIdentifier, undefined), 74 | }, 75 | longblob: ts.factory.createTypeReferenceNode( 76 | ts.factory.createIdentifier('Buffer'), 77 | undefined, 78 | ), 79 | longtext: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 80 | mediumblob: ts.factory.createTypeReferenceNode( 81 | ts.factory.createIdentifier('Buffer'), 82 | undefined, 83 | ), 84 | mediumint: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 85 | mediumtext: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 86 | smallint: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 87 | text: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 88 | time: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 89 | timestamp: ts.factory.createTypeReferenceNode( 90 | ts.factory.createIdentifier('Date'), 91 | undefined, 92 | ), 93 | tinyblob: ts.factory.createTypeReferenceNode( 94 | ts.factory.createIdentifier('Buffer'), 95 | undefined, 96 | ), 97 | tinyint: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 98 | tinytext: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 99 | varbinary: ts.factory.createTypeReferenceNode( 100 | ts.factory.createIdentifier('Buffer'), 101 | undefined, 102 | ), 103 | varchar: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 104 | year: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 105 | } satisfies Definitions 106 | -------------------------------------------------------------------------------- /src/utils/codegen/definitions/postgres.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript' 2 | 3 | import { 4 | jsonArrayTypeAlias, 5 | jsonIdentifier, 6 | jsonObjectTypeAlias, 7 | jsonTypeAlias, 8 | jsonValueTypeAlias, 9 | kyselyColumnTypeIdentifier, 10 | kyselyColumnTypeImportSpecifier, 11 | } from '../declarations.js' 12 | import { type DefinitionNode, type Definitions } from '../types.js' 13 | 14 | const json = { 15 | imports: { kysely: [kyselyColumnTypeImportSpecifier] }, 16 | declarations: [ 17 | jsonTypeAlias, 18 | jsonValueTypeAlias, 19 | jsonArrayTypeAlias, 20 | jsonObjectTypeAlias, 21 | ], 22 | value: ts.factory.createTypeReferenceNode(jsonIdentifier, undefined), 23 | } satisfies DefinitionNode 24 | 25 | const timestamp = { 26 | imports: { kysely: [kyselyColumnTypeImportSpecifier] }, 27 | declarations: [], 28 | value: ts.factory.createTypeReferenceNode(kyselyColumnTypeIdentifier, [ 29 | ts.factory.createTypeReferenceNode( 30 | ts.factory.createIdentifier('Date'), 31 | undefined, 32 | ), 33 | ts.factory.createUnionTypeNode([ 34 | ts.factory.createTypeReferenceNode( 35 | ts.factory.createIdentifier('Date'), 36 | undefined, 37 | ), 38 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 39 | ]), 40 | ts.factory.createUnionTypeNode([ 41 | ts.factory.createTypeReferenceNode( 42 | ts.factory.createIdentifier('Date'), 43 | undefined, 44 | ), 45 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 46 | ]), 47 | ]), 48 | } satisfies DefinitionNode 49 | 50 | export const postgresDefinitions = { 51 | bit: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 52 | bool: ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword), 53 | box: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 54 | bpchar: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 55 | bytea: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 56 | cidr: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 57 | date: timestamp, 58 | enum(column, table, enums) { 59 | const values = enums.get(`${table.schema}.${column.dataType}`)?.sort() 60 | if (!values) 61 | return ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword) 62 | return ts.factory.createUnionTypeNode( 63 | values.map((value) => 64 | ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(value)), 65 | ), 66 | ) 67 | }, 68 | float4: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 69 | float8: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 70 | inet: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 71 | int2: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 72 | int4: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 73 | int8: { 74 | imports: { kysely: [kyselyColumnTypeImportSpecifier] }, 75 | declarations: [], 76 | value: ts.factory.createTypeReferenceNode(kyselyColumnTypeIdentifier, [ 77 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 78 | ts.factory.createUnionTypeNode([ 79 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 80 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 81 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.BigIntKeyword), 82 | ]), 83 | ts.factory.createUnionTypeNode([ 84 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 85 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 86 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.BigIntKeyword), 87 | ]), 88 | ]), 89 | }, 90 | json, 91 | jsonb: json, 92 | line: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 93 | lseg: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 94 | macaddr: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 95 | money: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 96 | numeric: { 97 | imports: { kysely: [kyselyColumnTypeImportSpecifier] }, 98 | declarations: [], 99 | value: ts.factory.createTypeReferenceNode(kyselyColumnTypeIdentifier, [ 100 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 101 | ts.factory.createUnionTypeNode([ 102 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 103 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 104 | ]), 105 | ts.factory.createUnionTypeNode([ 106 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 107 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 108 | ]), 109 | ]), 110 | }, 111 | oid: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 112 | path: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 113 | polygon: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 114 | serial: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 115 | text: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 116 | time: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 117 | timestamp, 118 | timestamptz: timestamp, 119 | tsquery: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 120 | tsvector: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 121 | txid_snapshot: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 122 | uuid: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 123 | varbit: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 124 | varchar: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 125 | xml: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 126 | } satisfies Definitions 127 | -------------------------------------------------------------------------------- /src/utils/codegen/definitions/sqlite.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript' 2 | 3 | import { type Definitions } from '../types.js' 4 | 5 | export const sqliteDefinitions = { 6 | any: ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword), 7 | blob: ts.factory.createTypeReferenceNode( 8 | ts.factory.createIdentifier('Buffer'), 9 | undefined, 10 | ), 11 | boolean: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 12 | integer: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 13 | numeric: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 14 | real: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 15 | text: ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 16 | } satisfies Definitions 17 | -------------------------------------------------------------------------------- /src/utils/codegen/getEnums.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely' 2 | import type { Dialect } from './types.js' 3 | 4 | const mysqlEnumRegex = /^enum\((?:(?'.*'),?)+\)$/ 5 | 6 | export async function getEnums(db: Kysely, dialect: Dialect | undefined) { 7 | const enums = new Map() 8 | if (dialect === 'mysql') { 9 | const results = await db 10 | .withoutPlugins() 11 | .selectFrom('information_schema.COLUMNS') 12 | .select(['COLUMN_NAME', 'COLUMN_TYPE', 'TABLE_NAME', 'TABLE_SCHEMA']) 13 | .execute() 14 | .catch(() => []) 15 | 16 | for (const result of results) { 17 | const key = `${result.TABLE_SCHEMA}.${result.TABLE_NAME}.${result.COLUMN_NAME}` 18 | if (mysqlEnumRegex.test(result.COLUMN_TYPE)) { 19 | const match = mysqlEnumRegex.exec(result.COLUMN_TYPE) 20 | const enumValues = match?.groups?.values?.replace(/'/g, '')?.split(',') 21 | if (enumValues?.length) enums.set(key, enumValues) 22 | } 23 | } 24 | } else if (dialect === 'postgres') { 25 | const results = await db 26 | .withoutPlugins() 27 | .selectFrom('pg_type as type') 28 | .innerJoin('pg_enum as enum', 'type.oid', 'enum.enumtypid') 29 | .innerJoin( 30 | 'pg_catalog.pg_namespace as namespace', 31 | 'namespace.oid', 32 | 'type.typnamespace', 33 | ) 34 | .select(['namespace.nspname', 'type.typname', 'enum.enumlabel']) 35 | .execute() 36 | .catch(() => []) 37 | 38 | for (const result of results) { 39 | const key = `${result.nspname}.${result.typname}` 40 | if (enums.has(key)) 41 | enums.set(key, [...enums.get(key)!.values(), result.enumlabel]) 42 | else enums.set(key, [result.enumlabel]) 43 | } 44 | } 45 | 46 | return enums 47 | } 48 | -------------------------------------------------------------------------------- /src/utils/codegen/getTypes.test.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript' 2 | import { expect, test } from 'vitest' 3 | 4 | import { mysqlDefinitions } from './definitions/mysql.js' 5 | import { postgresDefinitions } from './definitions/postgres.js' 6 | import { getColumnType, getTypes } from './getTypes.js' 7 | 8 | function printPropertySignature(propertySignature: ts.PropertySignature) { 9 | const node = ts.factory.createTypeAliasDeclaration( 10 | undefined, 11 | ts.factory.createIdentifier('Table'), 12 | undefined, 13 | ts.factory.createTypeLiteralNode([propertySignature]), 14 | ) 15 | const printer = ts.createPrinter() 16 | return printer.printNode( 17 | ts.EmitHint.Unspecified, 18 | node, 19 | ts.createSourceFile('', '', ts.ScriptTarget.Latest), 20 | ) 21 | } 22 | 23 | test('getColumnType > Generated', () => { 24 | const res = getColumnType( 25 | { 26 | name: 'id', 27 | dataType: 'bigint', 28 | hasDefaultValue: false, 29 | isAutoIncrementing: true, 30 | isNullable: false, 31 | }, 32 | { 33 | name: 'user', 34 | columns: [], 35 | isView: false, 36 | }, 37 | new Map(), 38 | mysqlDefinitions, 39 | new Map(), 40 | new Map(), 41 | ) 42 | expect(printPropertySignature(res)).toMatchInlineSnapshot(` 43 | "type Table = { 44 | id: Generated; 45 | };" 46 | `) 47 | }) 48 | 49 | test('getColumnType > Generated > UnwrapColumnType', () => { 50 | const res = getColumnType( 51 | { 52 | name: 'created_at', 53 | dataType: 'timestamp', 54 | hasDefaultValue: true, 55 | isAutoIncrementing: false, 56 | isNullable: false, 57 | }, 58 | { 59 | name: 'user', 60 | columns: [], 61 | isView: false, 62 | }, 63 | new Map(), 64 | postgresDefinitions, 65 | new Map(), 66 | new Map(), 67 | ) 68 | expect(printPropertySignature(res)).toMatchInlineSnapshot(` 69 | "type Table = { 70 | created_at: Generated>>; 71 | };" 72 | `) 73 | }) 74 | 75 | test('getColumnType > nullable', () => { 76 | const res = getColumnType( 77 | { 78 | name: 'foo', 79 | dataType: 'varchar', 80 | hasDefaultValue: false, 81 | isAutoIncrementing: false, 82 | isNullable: true, 83 | }, 84 | { 85 | name: 'user', 86 | columns: [], 87 | isView: false, 88 | }, 89 | new Map(), 90 | mysqlDefinitions, 91 | new Map(), 92 | new Map(), 93 | ) 94 | expect(printPropertySignature(res)).toMatchInlineSnapshot(` 95 | "type Table = { 96 | foo: string | null; 97 | };" 98 | `) 99 | }) 100 | 101 | test('getColumnType > unknown definition', () => { 102 | const res = getColumnType( 103 | { 104 | name: 'foo', 105 | dataType: 'bar', 106 | hasDefaultValue: false, 107 | isAutoIncrementing: false, 108 | isNullable: false, 109 | }, 110 | { 111 | name: 'user', 112 | columns: [], 113 | isView: false, 114 | }, 115 | new Map(), 116 | mysqlDefinitions, 117 | new Map(), 118 | new Map(), 119 | ) 120 | expect(printPropertySignature(res)).toMatchInlineSnapshot(` 121 | "type Table = { 122 | foo: unknown; 123 | };" 124 | `) 125 | }) 126 | 127 | test('getColumnType > Generated', () => { 128 | const res = getTypes( 129 | [ 130 | { 131 | name: 'user', 132 | columns: [ 133 | { 134 | name: 'id', 135 | dataType: 'bigint', 136 | hasDefaultValue: false, 137 | isAutoIncrementing: true, 138 | isNullable: false, 139 | }, 140 | { 141 | name: 'created_at', 142 | dataType: 'datetime', 143 | hasDefaultValue: true, 144 | isAutoIncrementing: false, 145 | isNullable: false, 146 | }, 147 | { 148 | name: 'email', 149 | dataType: 'varchar', 150 | hasDefaultValue: false, 151 | isAutoIncrementing: false, 152 | isNullable: false, 153 | }, 154 | ], 155 | isView: false, 156 | }, 157 | ], 158 | new Map(), 159 | 'mysql', 160 | ) 161 | expect(res).toMatchInlineSnapshot(` 162 | "/** generated by kysely-migrate */ 163 | import { type Generated, type Selectable, type Insertable, type Updateable } from \\"kysely\\"; 164 | 165 | export type User = { 166 | id: Generated; 167 | created_at: Generated; 168 | email: string; 169 | }; 170 | 171 | export type UserInsertable = Insertable; 172 | 173 | export type UserSelectable = Selectable; 174 | 175 | export type UserUpdateable = Updateable; 176 | 177 | export interface DB { 178 | user: User; 179 | } 180 | 181 | " 182 | `) 183 | }) 184 | -------------------------------------------------------------------------------- /src/utils/codegen/getTypes.ts: -------------------------------------------------------------------------------- 1 | import { capitalCase } from 'change-case' 2 | import { type ColumnMetadata, type TableMetadata } from 'kysely' 3 | import ts from 'typescript' 4 | 5 | import { 6 | kyselyColumnTypeIdentifier, 7 | kyselyGeneratedIdentifier, 8 | kyselyGeneratedImportSpecifier, 9 | kyselyInsertableIdentifier, 10 | kyselyInsertableImportSpecifier, 11 | kyselySelectableIdentifier, 12 | kyselySelectableImportSpecifier, 13 | kyselyUpdateableIdentifier, 14 | kyselyUpdateableImportSpecifier, 15 | unwrapColumnTypeIdentifier, 16 | unwrapColumnTypeTypeAlias, 17 | } from './declarations.js' 18 | import { mysqlDefinitions } from './definitions/mysql.js' 19 | import { postgresDefinitions } from './definitions/postgres.js' 20 | import { sqliteDefinitions } from './definitions/sqlite.js' 21 | import { type Definitions, type Dialect } from './types.js' 22 | 23 | // Useful links: 24 | // - https://ts-ast-viewer.com 25 | // - https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API 26 | 27 | const dialectDefinitions = { 28 | mysql: mysqlDefinitions, 29 | postgres: postgresDefinitions, 30 | sqlite: sqliteDefinitions, 31 | } satisfies Record 32 | 33 | export function getTypes( 34 | tableMetadata: TableMetadata[], 35 | enums: Map, 36 | dialect: Dialect | undefined, 37 | customDefinitions: Definitions | undefined = {}, 38 | ) { 39 | // Get dialect node mapping 40 | if (!dialect && !customDefinitions) 41 | throw new Error( 42 | '`dialect` and/or `customDefinitions` required for codegen.', 43 | ) 44 | const definitions = { 45 | ...(dialect && dialectDefinitions[dialect]), 46 | ...customDefinitions, 47 | } 48 | 49 | const nodes = [] 50 | const importsMap: Map> = new Map() 51 | const typeDeclarationsMap: Map = new Map() 52 | 53 | // Create types 54 | const dbTypeParameters = [] 55 | for (const table of tableMetadata) { 56 | // Create type property for each column 57 | const columnProperties = [] 58 | for (const column of table.columns) { 59 | const columnProperty = getColumnType( 60 | column, 61 | table, 62 | enums, 63 | definitions, 64 | importsMap, 65 | typeDeclarationsMap, 66 | ) 67 | columnProperties.push(columnProperty) 68 | } 69 | 70 | // TODO: Handle schemas 71 | // https://kysely.dev/docs/recipes/schemas 72 | // Create table type alias 73 | const tableTypeName = capitalCase(table.name) 74 | const tableTypeIdentifier = ts.factory.createIdentifier(tableTypeName) 75 | const tableTypeAlias = ts.factory.createTypeAliasDeclaration( 76 | [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], 77 | tableTypeIdentifier, 78 | undefined, 79 | ts.factory.createTypeLiteralNode(columnProperties), 80 | ) 81 | nodes.push(tableTypeAlias) 82 | 83 | // Create `Selectable`, `Insertable` and `Updateable` wrappers for table type 84 | if (importsMap.has('kysely')) { 85 | const kyselyImports = importsMap.get('kysely')! 86 | kyselyImports.add(kyselySelectableImportSpecifier) 87 | if (!table.isView) { 88 | kyselyImports.add(kyselyInsertableImportSpecifier) 89 | kyselyImports.add(kyselyUpdateableImportSpecifier) 90 | } 91 | importsMap.set('kysely', kyselyImports) 92 | } else 93 | importsMap.set( 94 | 'kysely', 95 | new Set([ 96 | kyselySelectableImportSpecifier, 97 | ...(table.isView 98 | ? [] 99 | : [ 100 | kyselyInsertableImportSpecifier, 101 | kyselyUpdateableImportSpecifier, 102 | ]), 103 | ]), 104 | ) 105 | 106 | const insertableTypeAlias = createWrapperTypeAlias( 107 | tableTypeName, 108 | tableTypeIdentifier, 109 | 'insertable', 110 | ) 111 | nodes.push(insertableTypeAlias) 112 | if (!table.isView) { 113 | const selectableTypeAlias = createWrapperTypeAlias( 114 | tableTypeName, 115 | tableTypeIdentifier, 116 | 'selectable', 117 | ) 118 | const updateableTypeAlias = createWrapperTypeAlias( 119 | tableTypeName, 120 | tableTypeIdentifier, 121 | 'updateable', 122 | ) 123 | nodes.push(selectableTypeAlias, updateableTypeAlias) 124 | } 125 | 126 | // Create table type property for encompassing `DB` type 127 | const tableDbTypeParameter = ts.factory.createPropertySignature( 128 | undefined, 129 | table.name, 130 | undefined, 131 | ts.factory.createTypeReferenceNode(tableTypeIdentifier, undefined), 132 | ) 133 | dbTypeParameters.push(tableDbTypeParameter) 134 | } 135 | 136 | // Add in type declarations 137 | nodes.unshift(...typeDeclarationsMap.values()) 138 | 139 | // Add imports statement to start of nodes 140 | for (const [name, imports] of importsMap.entries()) { 141 | const importDeclaration = ts.factory.createImportDeclaration( 142 | undefined, 143 | ts.factory.createImportClause( 144 | false, 145 | undefined, 146 | ts.factory.createNamedImports([...imports.values()]), 147 | ), 148 | ts.factory.createStringLiteral(name), 149 | undefined, 150 | ) 151 | nodes.unshift(importDeclaration) 152 | } 153 | 154 | // Create `DB` type alias 155 | const dbNode = ts.factory.createInterfaceDeclaration( 156 | [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], 157 | ts.factory.createIdentifier('DB'), 158 | undefined, 159 | undefined, 160 | dbTypeParameters, 161 | ) 162 | nodes.push(dbNode) 163 | 164 | // Print and combine all nodes 165 | let content = '/** generated by kysely-migrate */\n' 166 | const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }) 167 | for (const node of nodes) { 168 | content += printer.printNode( 169 | ts.EmitHint.Unspecified, 170 | node, 171 | ts.createSourceFile('', '', ts.ScriptTarget.Latest), 172 | ) 173 | content += '\n\n' 174 | } 175 | 176 | return content 177 | } 178 | 179 | export function getColumnType( 180 | column: ColumnMetadata, 181 | table: TableMetadata, 182 | enums: Map, 183 | definitions: Definitions, 184 | importsMap: Map>, 185 | typeDeclarationsMap: Map, 186 | ) { 187 | let dataType: keyof typeof definitions = column.dataType 188 | // postgres enums have `dataType` set to enum object 189 | if (enums.has(`${table.schema}.${column.dataType}`)) dataType = 'enum' 190 | 191 | // Get type from lookup 192 | let type: ts.TypeNode 193 | if (dataType in definitions) { 194 | const definition = definitions[dataType] as Definitions[string] 195 | if ('value' in definition) { 196 | type = definition.value 197 | for (const [name, imports] of Object.entries(definition.imports)) { 198 | if (importsMap.has(name)) { 199 | const nameImports = importsMap.get(name)! 200 | importsMap.set(name, new Set([...nameImports, ...imports])) 201 | } else importsMap.set(name, new Set(imports)) 202 | } 203 | for (const declaration of definition.declarations) { 204 | typeDeclarationsMap.set(declaration.name.escapedText!, declaration) 205 | } 206 | } else if (typeof definition === 'function') { 207 | type = definition(column, table, enums) 208 | } else type = definition 209 | } else type = ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword) 210 | 211 | // Create node based on properties (e.g. nullable, default) 212 | let columnTypeNode: ts.TypeNode 213 | if (column.isNullable) 214 | columnTypeNode = ts.factory.createUnionTypeNode([ 215 | type, 216 | ts.factory.createLiteralTypeNode(ts.factory.createNull()), 217 | ]) 218 | else if (column.hasDefaultValue || column.isAutoIncrementing) { 219 | if (importsMap.has('kysely')) { 220 | const kyselyImports = importsMap.get('kysely')! 221 | kyselyImports.add(kyselyGeneratedImportSpecifier) 222 | importsMap.set('kysely', kyselyImports) 223 | } else { 224 | importsMap.set('kysely', new Set([kyselyGeneratedImportSpecifier])) 225 | } 226 | 227 | const node = 228 | typeDeclarationsMap.get( 229 | ((type as ts.TypeReferenceNode).typeName as ts.Identifier) 230 | ?.escapedText as string, 231 | )?.type ?? type 232 | const hasColumnType = 233 | ts.isTypeReferenceNode(node) && 234 | (node.typeName as ts.Identifier).escapedText === 235 | kyselyColumnTypeIdentifier.escapedText 236 | // Unwrap declarations already contained in `ColumnType` 237 | if (hasColumnType) { 238 | if (!typeDeclarationsMap.has(unwrapColumnTypeIdentifier.escapedText!)) 239 | typeDeclarationsMap.set( 240 | unwrapColumnTypeIdentifier.escapedText!, 241 | unwrapColumnTypeTypeAlias, 242 | ) 243 | columnTypeNode = ts.factory.createTypeReferenceNode( 244 | kyselyGeneratedIdentifier, 245 | [ 246 | ts.factory.createTypeReferenceNode(unwrapColumnTypeIdentifier, [ 247 | type, 248 | ]), 249 | ], 250 | ) 251 | } else 252 | columnTypeNode = ts.factory.createTypeReferenceNode( 253 | kyselyGeneratedIdentifier, 254 | [type], 255 | ) 256 | } else columnTypeNode = type 257 | 258 | // Create property 259 | return ts.factory.createPropertySignature( 260 | undefined, 261 | column.name, 262 | undefined, 263 | columnTypeNode, 264 | ) 265 | } 266 | 267 | function createWrapperTypeAlias( 268 | tableTypeName: string, 269 | tableTypeIdentifier: ts.Identifier, 270 | type: 'insertable' | 'selectable' | 'updateable', 271 | ) { 272 | let identifier: ts.Identifier 273 | if (type === 'insertable') identifier = kyselyInsertableIdentifier 274 | else if (type === 'selectable') identifier = kyselySelectableIdentifier 275 | else identifier = kyselyUpdateableIdentifier 276 | return ts.factory.createTypeAliasDeclaration( 277 | [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], 278 | ts.factory.createIdentifier(`${tableTypeName}${capitalCase(type)}`), 279 | undefined, 280 | ts.factory.createTypeReferenceNode(identifier, [ 281 | ts.factory.createTypeReferenceNode(tableTypeIdentifier, undefined), 282 | ]), 283 | ) 284 | } 285 | -------------------------------------------------------------------------------- /src/utils/codegen/types.ts: -------------------------------------------------------------------------------- 1 | import { type ColumnMetadata, type TableMetadata } from 'kysely' 2 | import ts from 'typescript' 3 | 4 | import { type Config } from '../../config.js' 5 | 6 | export type Dialect = NonNullable['dialect']> 7 | 8 | export type Definitions = Record< 9 | key, 10 | | ts.TypeNode 11 | | DefinitionNode 12 | | (( 13 | column: ColumnMetadata, 14 | table: TableMetadata, 15 | enums: Map, 16 | ) => ts.TypeNode) 17 | > 18 | 19 | export type DefinitionNode = { 20 | imports: { [key in 'kysely' | string]: readonly ts.ImportSpecifier[] } 21 | declarations: readonly ts.TypeAliasDeclaration[] 22 | value: ts.TypeNode 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/findConfig.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'node:fs' 2 | import { resolve } from 'node:path' 3 | import { findUp } from 'find-up' 4 | import pc from 'picocolors' 5 | 6 | // Do not reorder 7 | // In order of preference files are checked 8 | const configFiles = [ 9 | 'kysely-migrate.config.ts', 10 | 'kysely-migrate.config.mts', 11 | 'kysely.config.ts', 12 | 'kysely.config.mts', 13 | ] 14 | 15 | type FindConfigParameters = { 16 | config?: string | undefined 17 | root?: string | undefined 18 | } 19 | 20 | export async function findConfig( 21 | parameters: FindConfigParameters, 22 | throwIfNotFound?: throwIfNotFound, 23 | ): Promise { 24 | const { config, root } = parameters 25 | const rootDir = resolve(root || process.cwd()) 26 | if (config) { 27 | const path = resolve(rootDir, config) 28 | if (existsSync(path)) return path as any 29 | if (throwIfNotFound) 30 | throw new Error(`Config not found at ${pc.gray(config)}`) 31 | } 32 | 33 | const configPath = await findUp(configFiles, { cwd: rootDir }) 34 | if (throwIfNotFound && !configPath) throw new Error('Config not found') 35 | 36 | return configPath as any 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/getAppliedMigrationsCount.ts: -------------------------------------------------------------------------------- 1 | import { type MigrationResult } from 'kysely' 2 | 3 | export function getAppliedMigrationsCount(results: MigrationResult[]) { 4 | const appliedMigrations = results.filter( 5 | (result) => result.status === 'Success', 6 | ) 7 | const appliedCount = appliedMigrations.length 8 | return `Applied ${appliedCount} ${ 9 | appliedCount === 1 ? 'migration' : 'migrations' 10 | }.` 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/getMigrator.ts: -------------------------------------------------------------------------------- 1 | import nodeFs from 'node:fs/promises' 2 | import nodePath from 'node:path' 3 | import { FileMigrationProvider, Migrator } from 'kysely' 4 | 5 | import { type Config } from '../config.js' 6 | 7 | export function getMigrator(config: Config) { 8 | if ('migrator' in config && config.migrator) return config.migrator 9 | 10 | const { db, fs = nodeFs, path = nodePath } = config 11 | const migrationFolder = nodePath.resolve(config.migrationFolder) 12 | const provider = new FileMigrationProvider({ fs, migrationFolder, path }) 13 | return new Migrator({ db, provider }) 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/loadConfig.ts: -------------------------------------------------------------------------------- 1 | import { bundleRequire } from 'bundle-require' 2 | 3 | import { type Config } from '../config.js' 4 | 5 | type LoadConfigParameters = { 6 | configPath: string 7 | } 8 | 9 | export async function loadConfig( 10 | parameters: LoadConfigParameters, 11 | ): Promise { 12 | const { configPath } = parameters 13 | const res = await bundleRequire({ filepath: configPath }) 14 | let config = res.mod.default 15 | if (config.default) config = config.default 16 | if (typeof config !== 'function') return config 17 | return config() 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/loadEnv.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | 3 | import { loadEnv } from './loadEnv.js' 4 | 5 | test('loadEnv', () => { 6 | expect(loadEnv()).toMatchInlineSnapshot('{}') 7 | }) 8 | -------------------------------------------------------------------------------- /src/utils/loadEnv.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync, statSync } from 'node:fs' 2 | import { dirname, join } from 'node:path' 3 | import { parse } from 'dotenv' 4 | import { expand } from 'dotenv-expand' 5 | 6 | // https://github.com/vitejs/vite/blob/main/packages/vite/src/node/env.ts#L7 7 | export function loadEnv( 8 | config: { 9 | mode?: string | undefined 10 | envDir?: string | undefined 11 | } = {}, 12 | ): Record { 13 | const mode = config.mode 14 | if (mode === 'local') { 15 | throw new Error( 16 | `"local" cannot be used as a mode name because it conflicts with the .local postfix for .env files.`, 17 | ) 18 | } 19 | 20 | const envFiles = [ 21 | /** default file */ '.env', 22 | /** local file */ '.env.local', 23 | ...(mode 24 | ? [ 25 | /** mode file */ `.env.${mode}`, 26 | /** mode local file */ `.env.${mode}.local`, 27 | ] 28 | : []), 29 | ] 30 | 31 | const envDir = config.envDir ?? process.cwd() 32 | const parsed = Object.fromEntries( 33 | envFiles.flatMap((file) => { 34 | const path = lookupFile(envDir, [file], { 35 | pathOnly: true, 36 | rootDir: envDir, 37 | }) 38 | if (!path) return [] 39 | return Object.entries(parse(readFileSync(path))) 40 | }), 41 | ) 42 | 43 | try { 44 | // let environment variables use each other 45 | expand({ parsed }) 46 | } catch (error) { 47 | // custom error handling until https://github.com/motdotla/dotenv-expand/issues/65 is fixed upstream 48 | // check for message "TypeError: Cannot read properties of undefined (reading 'split')" 49 | if ((error as Error).message.includes('split')) { 50 | throw new Error( 51 | 'dotenv-expand failed to expand env vars. Maybe you need to escape `$`?', 52 | ) 53 | } 54 | throw error 55 | } 56 | 57 | return parsed 58 | } 59 | 60 | function lookupFile( 61 | dir: string, 62 | formats: string[], 63 | options?: { 64 | pathOnly?: boolean 65 | rootDir?: string 66 | predicate?: (file: string) => boolean 67 | }, 68 | ): string | undefined { 69 | for (const format of formats) { 70 | const fullPath = join(dir, format) 71 | if (existsSync(fullPath) && statSync(fullPath).isFile()) { 72 | const result = options?.pathOnly 73 | ? fullPath 74 | : readFileSync(fullPath, 'utf-8') 75 | if (!options?.predicate || options.predicate(result)) { 76 | return result 77 | } 78 | } 79 | } 80 | 81 | const parentDir = dirname(dir) 82 | if ( 83 | parentDir !== dir && 84 | (!options?.rootDir || parentDir.startsWith(options?.rootDir)) 85 | ) 86 | return lookupFile(parentDir, formats, options) 87 | 88 | return undefined 89 | } 90 | -------------------------------------------------------------------------------- /src/utils/logResultSet.ts: -------------------------------------------------------------------------------- 1 | import { type MigrationResultSet } from 'kysely' 2 | import pc from 'picocolors' 3 | 4 | import { S_BAR, S_ERROR, S_INFO, S_SUCCESS, message } from './clack.js' 5 | 6 | export async function logResultSet(resultSet: MigrationResultSet) { 7 | const { error, results = [] } = resultSet 8 | 9 | process.stdout.write(`${pc.gray(S_BAR)}\n`) 10 | 11 | let appliedCount = 0 12 | let usedError = false 13 | let index = 0 14 | for (const result of results) { 15 | index += 1 16 | const content = `${result.migrationName}.${result.direction.toLowerCase()}` 17 | switch (result.status) { 18 | case 'Success': { 19 | message(content, { symbol: pc.green(S_SUCCESS) }) 20 | appliedCount += 1 21 | break 22 | } 23 | case 'Error': { 24 | if (!usedError) { 25 | message(`${content}\n${pc.gray((error as Error).message)}`, { 26 | symbol: pc.red(S_ERROR), 27 | }) 28 | if (index !== results.length) 29 | process.stdout.write(`${pc.gray(S_BAR)}\n`) 30 | usedError = true 31 | } else message(content, { symbol: pc.red(S_ERROR) }) 32 | break 33 | } 34 | case 'NotExecuted': { 35 | message(content, { symbol: pc.blue(S_INFO) }) 36 | break 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | export const version = '0.0.16' 2 | -------------------------------------------------------------------------------- /test/config.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, MysqlDialect, PostgresDialect } from 'kysely' 2 | import { createPool } from 'mysql2' 3 | import { Pool } from 'pg' 4 | 5 | export const mysqlDb = new Kysely({ 6 | dialect: new MysqlDialect({ 7 | pool: createPool( 8 | `mysql://${process.env.database_username}:${process.env.database_password}@${process.env.database_host}/${process.env.database_name}`, 9 | ), 10 | }), 11 | }) 12 | 13 | export const postgresDb = new Kysely({ 14 | dialect: new PostgresDialect({ 15 | pool: new Pool({ 16 | database: process.env.database_name, 17 | host: process.env.database_host, 18 | password: process.env.database_password, 19 | user: process.env.database_username, 20 | }), 21 | }), 22 | }) 23 | -------------------------------------------------------------------------------- /test/migrations/basic/0001_add_user.js: -------------------------------------------------------------------------------- 1 | import { sql } from 'kysely' 2 | 3 | export async function up(db) { 4 | await db.schema 5 | .createTable('user') 6 | .addColumn('id', 'integer', (col) => col.autoIncrement().primaryKey()) 7 | .addColumn('created_at', 'timestamp', (col) => 8 | col.notNull().defaultTo(sql`now()`), 9 | ) 10 | .addColumn('email', 'varchar(200)', (col) => col.notNull().unique()) 11 | .addColumn('updated_at', 'timestamp', (col) => 12 | col.notNull().defaultTo(sql`now()`), 13 | ) 14 | .execute() 15 | } 16 | 17 | export async function down(db) { 18 | await db.schema.dropTable('user').ifExists().execute() 19 | } 20 | -------------------------------------------------------------------------------- /test/migrations/basic/0002_add_password.js: -------------------------------------------------------------------------------- 1 | export async function up(db) { 2 | await db.schema 3 | .createTable('password') 4 | .addColumn('id', 'integer', (col) => col.autoIncrement().primaryKey()) 5 | .addColumn('hash', 'varchar(200)', (col) => col.notNull()) 6 | .addColumn('user_id', 'integer', (col) => col.notNull()) 7 | .addForeignKeyConstraint( 8 | 'user_id', 9 | ['user_id'], 10 | 'user', 11 | ['id'], 12 | (callback) => callback.onDelete('cascade'), 13 | ) 14 | .execute() 15 | } 16 | 17 | export async function down(db) { 18 | await db.schema.dropTable('password').ifExists().execute() 19 | } 20 | -------------------------------------------------------------------------------- /test/migrations/mysql/0001_kitchen_sink.js: -------------------------------------------------------------------------------- 1 | import { sql } from 'kysely' 2 | 3 | export async function up(db) { 4 | await db.schema 5 | .createTable('foo') 6 | /// behavior 7 | .addColumn('behavior_autoincrementing', 'integer', (col) => 8 | col.autoIncrement().primaryKey(), 9 | ) 10 | .addColumn('behavior_default_value', 'varchar(10)', (col) => 11 | col.notNull().defaultTo('foo'), 12 | ) 13 | .addColumn('behavior_nullable', 'varchar(200)', (col) => col) 14 | .addColumn('behavior_unwrap_column_type', 'decimal', (col) => 15 | col.defaultTo(1).notNull(), 16 | ) 17 | /// definitions 18 | .addColumn('field_bigint', 'bigint', (col) => col.notNull()) 19 | .addColumn('field_binary', 'binary', (col) => col.notNull()) 20 | .addColumn('field_bit', 'bit', (col) => col.notNull()) 21 | .addColumn('field_blob', 'blob', (col) => col.notNull()) 22 | .addColumn('field_char', 'char', (col) => col.notNull()) 23 | .addColumn('field_date', 'date', (col) => col.notNull()) 24 | .addColumn('field_datetime', 'datetime', (col) => col.notNull()) 25 | .addColumn('field_decimal', 'decimal', (col) => col.notNull()) 26 | .addColumn('field_double', 'double', (col) => col.notNull()) 27 | .addColumn('field_enum', sql`enum('foo', 'bar', 'baz')`, (col) => 28 | col.notNull(), 29 | ) 30 | .addColumn('field_float', 'float', (col) => col.notNull()) 31 | .addColumn('field_int', 'int', (col) => col.notNull()) 32 | .addColumn('field_json', 'json', (col) => col.notNull()) 33 | .addColumn('field_longblob', 'longblob', (col) => col.notNull()) 34 | .addColumn('field_longtext', 'longtext', (col) => col.notNull()) 35 | .addColumn('field_mediumblob', 'mediumblob', (col) => col.notNull()) 36 | .addColumn('field_mediumint', 'mediumint', (col) => col.notNull()) 37 | .addColumn('field_mediumtext', 'mediumtext', (col) => col.notNull()) 38 | .addColumn('field_smallint', 'smallint', (col) => col.notNull()) 39 | .addColumn('field_text', 'text', (col) => col.notNull()) 40 | .addColumn('field_time', 'time', (col) => col.notNull()) 41 | .addColumn('field_timestamp', 'timestamp', (col) => col.notNull()) 42 | .addColumn('field_tinyblob', 'tinyblob', (col) => col.notNull()) 43 | .addColumn('field_tinyint', 'tinyint', (col) => col.notNull()) 44 | .addColumn('field_varbinary', 'varbinary(200)', (col) => col.notNull()) 45 | .addColumn('field_varchar', 'varchar(200)', (col) => col.notNull()) 46 | .addColumn('field_year', 'year', (col) => col.notNull()) 47 | .execute() 48 | } 49 | 50 | export async function down(db) { 51 | await db.schema.dropTable('foo').ifExists().execute() 52 | } 53 | -------------------------------------------------------------------------------- /test/migrations/postgres/0001_kitchen_sink.js: -------------------------------------------------------------------------------- 1 | import { sql } from 'kysely' 2 | 3 | export async function up(db) { 4 | await db.schema.createType('my_enum').asEnum(['foo', 'bar', 'baz']).execute() 5 | 6 | await db.schema 7 | .createTable('foo') 8 | /// behavior 9 | .addColumn('behavior_autoincrementing', 'serial', (col) => col.primaryKey()) 10 | .addColumn('behavior_default_value', 'text', (col) => 11 | col.notNull().defaultTo('foo'), 12 | ) 13 | .addColumn('behavior_nullable', 'varchar(200)', (col) => col) 14 | .addColumn('behavior_unwrap_column_type', 'timestamp', (col) => 15 | col.notNull().defaultTo(sql`now()`), 16 | ) 17 | /// definitions 18 | .addColumn('field_bit', 'bit', (col) => col.notNull()) 19 | .addColumn('field_bool', 'bool', (col) => col.notNull()) 20 | .addColumn('field_box', 'box', (col) => col.notNull()) 21 | .addColumn('field_bpchar', 'bpchar', (col) => col.notNull()) 22 | .addColumn('field_bytea', 'bytea', (col) => col.notNull()) 23 | .addColumn('field_cidr', 'cidr', (col) => col.notNull()) 24 | .addColumn('field_date', 'date', (col) => col.notNull()) 25 | .addColumn('field_enum', 'my_enum', (col) => col.notNull()) 26 | .addColumn('field_float4', 'float4', (col) => col.notNull()) 27 | .addColumn('field_float8', 'float8', (col) => col.notNull()) 28 | .addColumn('field_inet', 'inet', (col) => col.notNull()) 29 | .addColumn('field_int2', 'int2', (col) => col.notNull()) 30 | .addColumn('field_int4', 'int4', (col) => col.notNull()) 31 | .addColumn('field_int8', 'int8', (col) => col.notNull()) 32 | .addColumn('field_json', 'json', (col) => col.notNull()) 33 | .addColumn('field_jsonb', 'jsonb', (col) => col.notNull()) 34 | .addColumn('field_line', 'line', (col) => col.notNull()) 35 | .addColumn('field_lseg', 'lseg', (col) => col.notNull()) 36 | .addColumn('field_macaddr', 'macaddr', (col) => col.notNull()) 37 | .addColumn('field_money', 'money', (col) => col.notNull()) 38 | .addColumn('field_numeric', 'numeric', (col) => col.notNull()) 39 | .addColumn('field_oid', 'oid', (col) => col.notNull()) 40 | .addColumn('field_path', 'path', (col) => col.notNull()) 41 | .addColumn('field_polygon', 'polygon', (col) => col.notNull()) 42 | .addColumn('field_serial', 'serial', (col) => col.notNull()) 43 | .addColumn('field_text', 'text', (col) => col.notNull()) 44 | .addColumn('field_time', 'time', (col) => col.notNull()) 45 | .addColumn('field_timestamp', 'timestamp', (col) => col.notNull()) 46 | .addColumn('field_timestamptz', 'timestamptz', (col) => col.notNull()) 47 | .addColumn('field_tsquery', 'tsquery', (col) => col.notNull()) 48 | .addColumn('field_tsvector', 'tsvector', (col) => col.notNull()) 49 | .addColumn('field_txid_snapshot', 'txid_snapshot', (col) => col.notNull()) 50 | .addColumn('field_uuid', 'uuid', (col) => col.notNull()) 51 | .addColumn('field_varbit', 'varbit', (col) => col.notNull()) 52 | .addColumn('field_varchar', 'varchar', (col) => col.notNull()) 53 | .addColumn('field_xml', 'xml', (col) => col.notNull()) 54 | .execute() 55 | } 56 | 57 | export async function down(db) { 58 | await db.schema.dropTable('foo').ifExists().execute() 59 | await db.schema.dropType('my_enum').ifExists().execute() 60 | } 61 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts"], 3 | "exclude": ["src/**/*.test.ts"], 4 | "compilerOptions": { 5 | "incremental": true, 6 | 7 | "strict": true, 8 | "exactOptionalPropertyTypes": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "noImplicitOverride": true, 11 | "noImplicitReturns": true, 12 | "noUncheckedIndexedAccess": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "useDefineForClassFields": true, 16 | "useUnknownInCatchVariables": true, 17 | 18 | "allowJs": true, 19 | "checkJs": true, 20 | 21 | "forceConsistentCasingInFileNames": true, 22 | "verbatimModuleSyntax": true, 23 | 24 | "moduleResolution": "NodeNext", 25 | "module": "NodeNext", 26 | "target": "ES2021", 27 | "lib": ["ES2022", "DOM"], 28 | "sourceMap": true, 29 | 30 | "skipLibCheck": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "include": [".scripts/**/*.ts", "src/**/*.ts", "test/**/*.ts"], 4 | "exclude": [], 5 | "compilerOptions": { 6 | "types": ["bun-types"] 7 | } 8 | } 9 | --------------------------------------------------------------------------------