diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..51b5033 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# EditorConfig helps maintain consistent coding styles +# https://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7c0785c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,137 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18, 20, 22] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run linter + run: npm run lint + + - name: Run type check + run: npm run typecheck + + - name: Run tests + run: npm test + + - name: Build + run: npm run build + + publish-npm: + needs: test + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Check if version changed + id: version + run: | + PACKAGE_VERSION=$(node -p "require('./package.json').version") + NPM_VERSION=$(npm view supabase-sql-dev-runner version 2>/dev/null || echo "0.0.0") + if [ "$PACKAGE_VERSION" != "$NPM_VERSION" ]; then + echo "changed=true" >> $GITHUB_OUTPUT + echo "Version changed from $NPM_VERSION to $PACKAGE_VERSION" + else + echo "changed=false" >> $GITHUB_OUTPUT + echo "Version unchanged at $PACKAGE_VERSION" + fi + + - name: Publish to npm + if: steps.version.outputs.changed == 'true' + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + publish-github: + needs: test + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + registry-url: 'https://npm.pkg.github.com' + scope: '@4riel' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Check if version changed on GitHub Packages + id: version + run: | + PACKAGE_VERSION=$(node -p "require('./package.json').version") + # Try to get version from GitHub Packages + GPR_VERSION=$(npm view @4riel/supabase-sql-dev-runner version --registry=https://npm.pkg.github.com 2>/dev/null || echo "0.0.0") + if [ "$PACKAGE_VERSION" != "$GPR_VERSION" ]; then + echo "changed=true" >> $GITHUB_OUTPUT + echo "Version changed from $GPR_VERSION to $PACKAGE_VERSION" + else + echo "changed=false" >> $GITHUB_OUTPUT + echo "Version unchanged at $PACKAGE_VERSION" + fi + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish to GitHub Packages + if: steps.version.outputs.changed == 'true' + run: | + # Create a scoped package.json for GitHub Packages + node -e " + const pkg = require('./package.json'); + pkg.name = '@4riel/supabase-sql-dev-runner'; + pkg.publishConfig = { registry: 'https://npm.pkg.github.com' }; + require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2)); + " + npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec74515 --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# SQL files directory (user-generated) +sql/ + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment variables +.env +.env.local +.env.*.local +.env.development +.env.test +.env.production + +# IDE and editors +.idea/ +.vscode/ +*.swp +*.swo +*.sublime-workspace +*.sublime-project +.project +.classpath +.settings/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +desktop.ini + +# Test coverage +coverage/ +.nyc_output/ + +# Package manager locks (keep package-lock.json for npm) +yarn.lock +pnpm-lock.yaml + +# Temporary files +*.tmp +*.temp +*.bak +*~ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Tarball from npm pack +*.tgz diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..bb2b54b --- /dev/null +++ b/.npmignore @@ -0,0 +1,31 @@ +# Source files (we ship compiled code) +src/ +tests/ +scripts/ + +# Config files +tsconfig*.json +vitest.config.ts +eslint.config.js +.eslintrc* +.prettierrc +.prettierrc* +.editorconfig +.nvmrc + +# Development files +.gitignore +.github/ +CHANGELOG.md + +# Misc +*.log +coverage/ +.env* + +# User-generated directories +sql/ +logs/ + +# AI assistant context file +CLAUDE.md diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..b6b0fde --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fd5da8c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2025-12-10 + +### Added + +- Initial release +- Comprehensive test suite with 165 tests across 8 test files +- Test coverage for: CLI parsing, connection handling, logging, file scanning, SQL execution, and integration flows +- Sequential SQL file execution with alphabetical ordering +- Transaction safety with automatic rollback on errors +- Savepoint support for granular rollback per file +- Human confirmation prompt before execution (configurable) +- CLI tool with extensive options (`sql-runner`, `supabase-sql-runner`) +- Programmatic API with `SqlRunner` class and `runSqlScripts` convenience function +- Dual module support (ESM and CommonJS) +- Full TypeScript type definitions +- SSL enabled by default for secure connections +- Password masking in logs +- Verbose mode for detailed output +- Dry run mode to preview execution +- File filtering with `--only` and `--skip` options +- Environment file loading (`.env` support) +- Execution logging to file +- Progress callbacks (`onBeforeFile`, `onAfterFile`, `onComplete`, `onError`) +- SQL NOTICE message handling via `onNotice` callback diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..132a286 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Ariel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e4f2274 --- /dev/null +++ b/README.md @@ -0,0 +1,385 @@ +# supabase-sql-dev-runner + +[![npm version](https://img.shields.io/npm/v/supabase-sql-dev-runner.svg)](https://www.npmjs.com/package/supabase-sql-dev-runner) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +Run SQL scripts on Supabase with transaction safety. No more copy-pasting into the dashboard. + +```bash +npx sql-runner -y +``` + +## Why? + +We've all been there: you have 10 SQL files to set up your dev database. You open Supabase Dashboard, copy-paste each file, run them one by one, pray you didn't miss anything... and if something fails halfway through? Good luck figuring out what state your DB is in. + +This tool fixes that. One command, all your SQL files run in order, wrapped in a transaction. If anything fails, everything rolls back. Simple. + +> ⚠️ **This is a development tool.** For production migrations, use [Supabase Migrations](https://supabase.com/docs/guides/deployment/database-migrations) or similar tools with proper version control and audit trails. + +## Install + +```bash +npm install supabase-sql-dev-runner +``` + +## Setup + +### 1. Get your Database URL + +Go to [Supabase Dashboard](https://supabase.com/dashboard) → Select your project → Click **Connect** + +You'll see three connection types: + +| Type | Port | When to use | +|------|------|-------------| +| **Session Pooler** | 5432 | ✅ **Use this one** - supports long transactions | +| Transaction Pooler | 6543 | Serverless functions (short-lived) | +| Direct Connection | 5432 | Requires IPv6 | + +Copy the **Session Pooler** connection string. It looks like this: + +``` +postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:5432/postgres +``` + +### 2. Set the environment variable + +```bash +# Linux/macOS +export DATABASE_URL="postgres://postgres.abc123:mypassword@aws-0-us-east-1.pooler.supabase.com:5432/postgres" + +# Windows (PowerShell) +$env:DATABASE_URL="postgres://postgres.abc123:mypassword@aws-0-us-east-1.pooler.supabase.com:5432/postgres" + +# Or create a .env file (recommended) +echo 'DATABASE_URL="postgres://postgres.abc123:mypassword@aws-0-us-east-1.pooler.supabase.com:5432/postgres"' > .env +``` + +> 💡 Add `.env` to your `.gitignore` - never commit database credentials! + +### 3. Create your SQL files + +Create a `sql/` folder with numbered files: + +``` +sql/ +├── 00_extensions.sql +├── 01_tables.sql +├── 02_functions.sql +├── 03_triggers.sql +├── 04_views.sql +├── 05_rls_policies.sql +└── 06_seed.sql +``` + +Files run in alphabetical order. Use numeric prefixes to control the sequence. + +### 4. Run! + +```bash +npx sql-runner +``` + +That's it! 🎉 + +## CLI + +```bash +sql-runner [directory] [options] + +Options: + -y, --yes Skip confirmation + -d, --directory SQL folder (default: ./sql) + -u, --database-url Database URL + -e, --env-file Path to .env file (default: .env) + --dry-run Preview without executing + --only Run specific files (comma-separated) + --skip Skip specific files (comma-separated) + --watch, -w Re-run on file changes + --verbose Detailed output + --no-logs Disable file logging + -h, --help Show help +``` + +### Examples + +```bash +# Run all SQL files +sql-runner + +# Different directory +sql-runner ./migrations + +# Skip confirmation +sql-runner -y + +# Preview what would run +sql-runner --dry-run + +# Run specific files only +sql-runner --only "01_tables.sql,02_functions.sql" + +# Skip seed data +sql-runner --skip "06_seed.sql" + +# Watch mode - re-run on save +sql-runner --watch + +# Combine options +sql-runner ./sql -y --verbose --skip "06_seed.sql" +``` + +## Configuration File + +Instead of passing CLI arguments every time, you can create a configuration file. The tool automatically searches for configuration in several places: + +| File | Format | +|------|--------| +| `package.json` | `"sql-runner"` field | +| `.sql-runnerrc` | JSON or YAML | +| `.sql-runnerrc.json` | JSON | +| `.sql-runnerrc.yaml` / `.sql-runnerrc.yml` | YAML | +| `.sql-runnerrc.js` / `.sql-runnerrc.cjs` / `.sql-runnerrc.mjs` | JavaScript | +| `sql-runner.config.js` / `sql-runner.config.cjs` / `sql-runner.config.mjs` | JavaScript | +| `sql-runner.config.ts` | TypeScript | + +### Example configurations + +**In `package.json`:** + +```json +{ + "name": "my-project", + "sql-runner": { + "directory": "./database/scripts", + "verbose": true, + "skip": ["06_seed.sql"] + } +} +``` + +**Or `.sql-runnerrc.json`:** + +```json +{ + "directory": "./database/scripts", + "verbose": true, + "skip": ["06_seed.sql"], + "yes": true +} +``` + +**Or `.sql-runnerrc.yaml`:** + +```yaml +directory: ./database/scripts +verbose: true +skip: + - 06_seed.sql +yes: true +``` + +**Or `sql-runner.config.js`:** + +```js +export default { + directory: './database/scripts', + verbose: true, + skip: ['06_seed.sql'], + yes: true, +}; +``` + +### Configuration options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `directory` | `string` | `./sql` | SQL files directory | +| `databaseUrl` | `string` | - | Database connection URL | +| `envFile` | `string` | `.env` | Path to .env file | +| `yes` | `boolean` | `false` | Skip confirmation prompt | +| `confirmationPhrase` | `string` | `CONFIRM` | Confirmation text | +| `verbose` | `boolean` | `false` | Verbose output | +| `dryRun` | `boolean` | `false` | Preview without executing | +| `noLogs` | `boolean` | `false` | Disable file logging | +| `logDirectory` | `string` | `./logs` | Log files directory | +| `only` | `string[]` | - | Run only these files | +| `skip` | `string[]` | - | Skip these files | +| `watch` | `boolean` | `false` | Watch mode | +| `ssl` | `boolean` | `true` | Use SSL connection | +| `filePattern` | `string` | `\.sql$` | Regex for SQL files | +| `ignorePattern` | `string` | `^_ignored\|README` | Regex for ignored files | + +### Priority order + +Configuration is merged in this order (highest priority first): + +1. **CLI arguments** - Always win +2. **Config file** - Project-level defaults +3. **Built-in defaults** - Fallback values + +This means you can set project defaults in your config file and override them with CLI flags when needed: + +```bash +# Uses config file defaults, but skips a different file this time +sql-runner --skip "07_dev_data.sql" +``` + +## Programmatic Usage + +```typescript +import { SqlRunner } from 'supabase-sql-dev-runner'; + +const runner = new SqlRunner({ + databaseUrl: process.env.DATABASE_URL, + sqlDirectory: './sql', +}); + +const result = await runner.run(); +console.log(`Executed ${result.successfulFiles} files`); +``` + +### With callbacks + +```typescript +const runner = new SqlRunner({ + databaseUrl: process.env.DATABASE_URL, + sqlDirectory: './sql', + requireConfirmation: false, + verbose: true, + + onBeforeFile: (file, index, total) => { + console.log(`[${index + 1}/${total}] ${file}`); + }, + + onAfterFile: (result) => { + if (result.success) { + console.log(`✓ ${result.fileName} (${result.durationMs}ms)`); + } else { + console.log(`✗ ${result.fileName}: ${result.error?.message}`); + } + }, + + onNotice: (message) => { + console.log(`[SQL] ${message}`); + }, + + onComplete: (summary) => { + console.log(`Done! ${summary.successfulFiles}/${summary.totalFiles} files`); + }, +}); + +await runner.run(); +``` + +### Convenience function + +```typescript +import { runSqlScripts } from 'supabase-sql-dev-runner'; + +await runSqlScripts({ + databaseUrl: process.env.DATABASE_URL, + sqlDirectory: './sql', +}); +``` + +### Run options + +```typescript +const result = await runner.run({ + skipConfirmation: true, + onlyFiles: ['01_tables.sql', '02_functions.sql'], + skipFiles: ['06_seed.sql'], + dryRun: false, +}); +``` + +## How It Works + +- Files execute in alphabetical order (`00_`, `01_`, `02_`...) +- Everything runs in a single transaction +- Each file gets a savepoint for precise error tracking +- If any file fails, all changes roll back +- Files starting with `_ignored` or `README` are skipped + +## Configuration + +| Option | Default | Description | +|--------|---------|-------------| +| `databaseUrl` | - | PostgreSQL connection string (required) | +| `sqlDirectory` | `./sql` | Folder containing SQL files | +| `requireConfirmation` | `true` | Prompt before executing | +| `confirmationPhrase` | `CONFIRM` | Text to type for confirmation | +| `ssl` | `true` | Use SSL connection | +| `verbose` | `false` | Detailed logging | +| `logDirectory` | `./logs` | Log file location (`null` to disable) | +| `filePattern` | `/\.sql$/` | Regex to match SQL files | +| `ignorePattern` | `/^_ignored\|README/` | Regex to ignore files | + +## Error Handling + +When something fails, you get detailed PostgreSQL error info: + +```typescript +interface SqlRunnerError { + message: string; // Error message + code?: string; // PostgreSQL error code (e.g., "42P01") + detail?: string; // Additional detail + hint?: string; // Suggestion for fixing + position?: string; // Position in SQL + fileName?: string; // File that caused the error +} +``` + +Example error output: + +``` +✗ 03_tables.sql failed! + +Error: relation "users" already exists +Code: 42P07 +Hint: Use CREATE TABLE IF NOT EXISTS to avoid this error + +Transaction rolled back. No changes were made. +``` + +## Execution Summary + +The `run()` method returns a summary: + +```typescript +interface ExecutionSummary { + totalFiles: number; + successfulFiles: number; + failedFiles: number; + totalDurationMs: number; + results: FileExecutionResult[]; + allSuccessful: boolean; + committed: boolean; + ignoredFiles: string[]; +} +``` + +## Troubleshooting + +**"Connection refused"** +- Check your `DATABASE_URL` is correct +- Make sure you're using Session Pooler (port 5432) +- Verify your IP is allowed in Supabase Dashboard → Settings → Database + +**"Prepared statement already exists"** +- You're using Transaction Pooler (port 6543). Switch to Session Pooler (port 5432) + +**Files not running** +- Check files end with `.sql` +- Make sure they don't start with `_ignored` +- Use `--verbose` to see which files are found + +## License + +MIT + +--- + +Made with ❤️ by [4riel](https://github.com/4riel) diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..0a49be1 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,105 @@ +import eslint from '@eslint/js'; +import tseslint from '@typescript-eslint/eslint-plugin'; +import tsparser from '@typescript-eslint/parser'; + +export default [ + { + ignores: ['dist/**', 'node_modules/**', 'coverage/**', '*.js', '*.cjs', '*.mjs'], + }, + eslint.configs.recommended, + { + files: ['src/**/*.ts'], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + globals: { + // Node.js globals + console: 'readonly', + process: 'readonly', + __dirname: 'readonly', + Buffer: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + // Web APIs available in Node.js + URL: 'readonly', + URLSearchParams: 'readonly', + // Node.js namespace for types + NodeJS: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': tseslint, + }, + rules: { + // TypeScript specific rules + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, + ], + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-non-null-assertion': 'off', + + // General rules + 'no-console': 'off', + 'no-unused-vars': 'off', // Use TypeScript's version + 'prefer-const': 'error', + 'no-var': 'error', + eqeqeq: ['error', 'always'], + curly: ['warn', 'multi-line'], + }, + }, + { + files: ['tests/**/*.ts'], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + globals: { + // Node.js globals + console: 'readonly', + process: 'readonly', + __dirname: 'readonly', + Buffer: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + // Node.js namespace for types + NodeJS: 'readonly', + // Vitest globals + describe: 'readonly', + it: 'readonly', + expect: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + beforeAll: 'readonly', + afterAll: 'readonly', + vi: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': tseslint, + }, + rules: { + // More relaxed rules for tests + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, + ], + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + 'no-console': 'off', + 'no-unused-vars': 'off', + }, + }, +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..eac5c85 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3287 @@ +{ + "name": "supabase-sql-dev-runner", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "supabase-sql-dev-runner", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "cosmiconfig": "^9.0.0", + "pg": "^8.16.3" + }, + "bin": { + "sql-runner": "dist/cli/cli/cli.js", + "supabase-sql-runner": "dist/cli/cli/cli.js" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.2", + "@types/pg": "^8.15.6", + "@typescript-eslint/eslint-plugin": "^8.49.0", + "@typescript-eslint/parser": "^8.49.0", + "eslint": "^9.39.1", + "prettier": "^3.7.4", + "rimraf": "^6.1.2", + "typescript": "^5.9.3", + "vitest": "^4.0.15" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.2.tgz", + "integrity": "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/pg": { + "version": "8.15.6", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", + "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", + "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/type-utils": "8.49.0", + "@typescript-eslint/utils": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.49.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", + "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", + "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.49.0", + "@typescript-eslint/types": "^8.49.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", + "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", + "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", + "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", + "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", + "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.49.0", + "@typescript-eslint/tsconfig-utils": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", + "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", + "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.49.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz", + "integrity": "sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.15.tgz", + "integrity": "sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.15", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.15.tgz", + "integrity": "sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.15.tgz", + "integrity": "sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.15", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.15.tgz", + "integrity": "sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.15", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.15.tgz", + "integrity": "sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.15.tgz", + "integrity": "sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rimraf": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", + "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.0", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.15.tgz", + "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.15", + "@vitest/mocker": "4.0.15", + "@vitest/pretty-format": "4.0.15", + "@vitest/runner": "4.0.15", + "@vitest/snapshot": "4.0.15", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.15", + "@vitest/browser-preview": "4.0.15", + "@vitest/browser-webdriverio": "4.0.15", + "@vitest/ui": "4.0.15", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5f4de81 --- /dev/null +++ b/package.json @@ -0,0 +1,89 @@ +{ + "name": "supabase-sql-dev-runner", + "version": "1.0.0", + "description": "Execute SQL scripts sequentially on Supabase PostgreSQL with transaction safety, savepoints, and automatic rollback. Perfect for development database setup and AI agent integration.", + "author": "4riel", + "license": "MIT", + "keywords": [ + "supabase", + "postgresql", + "sql", + "database", + "migration", + "development", + "scripts", + "runner", + "transaction", + "rollback" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/cjs/index.cjs", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/types/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/types/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + } + }, + "bin": { + "supabase-sql-runner": "./dist/cli/cli/cli.js", + "sql-runner": "./dist/cli/cli/cli.js" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "version:sync": "node scripts/sync-version.js", + "build": "npm run version:sync && npm run build:esm && npm run build:cjs && npm run build:types && npm run build:types:cjs && npm run build:cli", + "build:esm": "tsc -p tsconfig.esm.json", + "build:cjs": "tsc -p tsconfig.cjs.json && node scripts/fix-cjs.js", + "build:types": "tsc -p tsconfig.types.json", + "build:types:cjs": "node scripts/copy-types.js", + "build:cli": "tsc -p tsconfig.cli.json", + "clean": "rimraf dist", + "prepublishOnly": "npm run clean && npm run build && npm run test", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint src tests", + "format": "prettier --write \"src/**/*.ts\"", + "typecheck": "tsc --noEmit", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "cosmiconfig": "^9.0.0", + "pg": "^8.16.3" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.2", + "@types/pg": "^8.15.6", + "@typescript-eslint/eslint-plugin": "^8.49.0", + "@typescript-eslint/parser": "^8.49.0", + "eslint": "^9.39.1", + "prettier": "^3.7.4", + "rimraf": "^6.1.2", + "typescript": "^5.9.3", + "vitest": "^4.0.15" + }, + "engines": { + "node": ">=18.0.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/4riel/supabase-sql-dev-runner.git" + }, + "bugs": { + "url": "https://github.com/4riel/supabase-sql-dev-runner/issues" + }, + "homepage": "https://github.com/4riel/supabase-sql-dev-runner#readme" +} diff --git a/scripts/copy-types.js b/scripts/copy-types.js new file mode 100644 index 0000000..2b26a8a --- /dev/null +++ b/scripts/copy-types.js @@ -0,0 +1,51 @@ +/** + * Copy type declarations for CJS support + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const typesDir = path.join(__dirname, '..', 'dist', 'types'); + +/** + * Recursively process directory to create .d.cts files + */ +function processDirectory(dir) { + if (!fs.existsSync(dir)) { + console.log('Types directory not found:', dir); + return; + } + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + processDirectory(fullPath); + } else if (entry.name.endsWith('.d.ts')) { + // Read content + let content = fs.readFileSync(fullPath, 'utf8'); + + // Fix imports to use .cjs extension + content = content.replace( + /from ['"]([^'"]+)\.js['"]/g, + "from '$1.cjs'" + ); + + // Write .d.cts file + const newPath = fullPath.replace(/\.d\.ts$/, '.d.cts'); + fs.writeFileSync(newPath, content); + + console.log(`Created: ${path.basename(newPath)}`); + } + } +} + +console.log('Creating CJS type declarations...'); +processDirectory(typesDir); +console.log('Done!'); diff --git a/scripts/fix-cjs.js b/scripts/fix-cjs.js new file mode 100644 index 0000000..a3ffb38 --- /dev/null +++ b/scripts/fix-cjs.js @@ -0,0 +1,56 @@ +/** + * Post-build script to fix CommonJS output + * - Renames .js files to .cjs + * - Fixes import paths to use .cjs extension + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const cjsDir = path.join(__dirname, '..', 'dist', 'cjs'); + +/** + * Recursively process directory + */ +function processDirectory(dir) { + if (!fs.existsSync(dir)) { + console.log('CJS directory not found:', dir); + return; + } + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + processDirectory(fullPath); + } else if (entry.name.endsWith('.js')) { + // Read and fix imports + let content = fs.readFileSync(fullPath, 'utf8'); + + // Fix require paths to use .cjs + content = content.replace( + /require\("([^"]+)\.js"\)/g, + 'require("$1.cjs")' + ); + + // Write with .cjs extension + const newPath = fullPath.replace(/\.js$/, '.cjs'); + fs.writeFileSync(newPath, content); + + // Remove old .js file + fs.unlinkSync(fullPath); + + console.log(`Converted: ${entry.name} -> ${path.basename(newPath)}`); + } + } +} + +console.log('Fixing CommonJS output...'); +processDirectory(cjsDir); +console.log('Done!'); diff --git a/scripts/sync-version.js b/scripts/sync-version.js new file mode 100644 index 0000000..54c2474 --- /dev/null +++ b/scripts/sync-version.js @@ -0,0 +1,63 @@ +/** + * Sync version across all files in the codebase + * + * Reads version from package.json and updates: + * - src/cli/help.ts (DEFAULT_PACKAGE_INFO) + * - src/ui/renderer.ts (UIRenderer default) + * + * Usage: node scripts/sync-version.js + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.join(__dirname, '..'); + +// Read version from package.json +const packageJson = JSON.parse( + fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8') +); +const version = packageJson.version; + +console.log(`Syncing version: ${version}\n`); + +// Files to update with their patterns +const filesToUpdate = [ + { + path: 'src/cli/help.ts', + pattern: /(version:\s*')[\d.]+(')/, + replacement: `$1${version}$2`, + }, + { + path: 'src/ui/renderer.ts', + pattern: /(this\.version\s*=\s*options\.version\s*\?\?\s*')[\d.]+(')/, + replacement: `$1${version}$2`, + }, +]; + +let updated = 0; + +for (const file of filesToUpdate) { + const filePath = path.join(rootDir, file.path); + + if (!fs.existsSync(filePath)) { + console.log(` Skip: ${file.path} (not found)`); + continue; + } + + const content = fs.readFileSync(filePath, 'utf8'); + const newContent = content.replace(file.pattern, file.replacement); + + if (content !== newContent) { + fs.writeFileSync(filePath, newContent); + console.log(` Updated: ${file.path}`); + updated++; + } else { + console.log(` OK: ${file.path} (already at ${version})`); + } +} + +console.log(`\nDone! ${updated} file(s) updated.`); diff --git a/src/cli/application.ts b/src/cli/application.ts new file mode 100644 index 0000000..c5fec83 --- /dev/null +++ b/src/cli/application.ts @@ -0,0 +1,207 @@ +/** + * CLI Application + * + * Main orchestrator that coordinates all CLI components. + * Follows Dependency Inversion - depends on abstractions, not concrete implementations. + */ + +import type { CliOutput, FileSystem, ExitHandler } from './types.js'; +import { ArgParser } from './arg-parser.js'; +import { EnvLoader, defaultFileSystem, defaultProcessEnv } from './env-loader.js'; +import { CliValidator } from './validators.js'; +import { HelpDisplay, defaultCliOutput } from './help.js'; +import { CliExecutor } from './executor.js'; +import { ConfigLoader } from './config-loader.js'; +import { ConfigMerger, type MergedConfig } from './config-merger.js'; + +/** + * Application dependencies (for DI) + */ +export interface CliDependencies { + output: CliOutput; + fileSystem: FileSystem; + exitHandler: ExitHandler; +} + +/** + * Default exit handler + */ +const defaultExitHandler: ExitHandler = { + exit: (code: number) => process.exit(code), +}; + +/** + * Create default dependencies + */ +export function createDefaultDependencies(): CliDependencies { + return { + output: defaultCliOutput, + fileSystem: defaultFileSystem, + exitHandler: defaultExitHandler, + }; +} + +/** + * CLI Application class + * + * Orchestrates the entire CLI workflow using injected dependencies. + */ +export class CliApplication { + private argParser: ArgParser; + private envLoader: EnvLoader; + private validator: CliValidator; + private helpDisplay: HelpDisplay; + private executor: CliExecutor; + private configLoader: ConfigLoader; + private configMerger: ConfigMerger; + private output: CliOutput; + private exitHandler: ExitHandler; + + constructor(deps: Partial = {}) { + const fullDeps = { ...createDefaultDependencies(), ...deps }; + + this.output = fullDeps.output; + this.exitHandler = fullDeps.exitHandler; + + this.argParser = new ArgParser(); + this.envLoader = new EnvLoader(fullDeps.fileSystem, defaultProcessEnv); + this.validator = new CliValidator(fullDeps.fileSystem); + this.helpDisplay = new HelpDisplay(fullDeps.output, fullDeps.fileSystem); + this.executor = new CliExecutor(fullDeps.output); + this.configLoader = new ConfigLoader(); + this.configMerger = new ConfigMerger(); + } + + /** + * Run the CLI application + */ + async run(argv: string[]): Promise { + // Parse CLI arguments + const cliArgs = this.argParser.parse(argv); + + // Handle help and version (before loading config) + if (cliArgs.help) { + this.helpDisplay.showHelp(); + this.exitHandler.exit(0); + } + + if (cliArgs.version) { + this.helpDisplay.showVersion(); + this.exitHandler.exit(0); + } + + // Load configuration file + const configResult = await this.configLoader.load(); + + // Merge CLI args with config file + const args = this.configMerger.merge(cliArgs, configResult.config, configResult.filepath); + + // Validate argument combinations + this.validateArgumentCombinations(args); + + // Show config file info in verbose mode + if (args.verbose && args.configFileUsed) { + this.output.log(`Using config file: ${args.configFilePath}`); + } + + // Load environment + this.loadEnvironment(args); + + // Get database URL + const databaseUrl = this.getDatabaseUrl(args); + + // Validate SQL directory + const sqlDirectory = this.validateSqlDirectory(args); + + // Execute + const config = { databaseUrl, sqlDirectory, args }; + + if (args.watch) { + await this.executor.executeWithWatch(config); + // Watch mode keeps running, don't exit + } else { + const result = await this.executor.execute(config); + if (!result.success) { + this.exitHandler.exit(result.exitCode); + } + } + } + + /** + * Load environment from file + */ + private loadEnvironment(args: MergedConfig): void { + const envFile = args.envFile ?? '.env'; + + if (this.envLoader.exists(envFile)) { + const result = this.envLoader.load(envFile); + if (!result.success && args.envFile) { + // Only error if user explicitly specified the env file + this.output.error(`Error: ${result.error}`); + this.exitHandler.exit(1); + } + } else if (args.envFile) { + // User specified an env file that doesn't exist + const validation = this.validator.validateEnvFile(args.envFile); + if (!validation.valid) { + this.output.error(`Error: ${validation.error}`); + this.exitHandler.exit(1); + } + } + } + + /** + * Get and validate database URL + */ + private getDatabaseUrl(args: MergedConfig): string { + const databaseUrl = args.databaseUrl ?? this.envLoader.get('DATABASE_URL'); + + const validation = this.validator.validateDatabaseUrl(databaseUrl); + if (!validation.valid) { + this.output.error(`Error: ${validation.error}`); + this.exitHandler.exit(1); + } + + return databaseUrl!; + } + + /** + * Validate and resolve SQL directory + */ + private validateSqlDirectory(args: MergedConfig): string { + const validation = this.validator.validateSqlDirectory(args.sqlDirectory); + if (!validation.valid) { + this.output.error(`Error: ${validation.error}`); + this.exitHandler.exit(1); + } + + return this.validator.resolveSqlDirectory(args.sqlDirectory); + } + + /** + * Validate argument combinations + */ + private validateArgumentCombinations(args: MergedConfig): void { + const validation = this.validator.validateArgumentCombinations(args); + + if (!validation.valid) { + this.output.error(`Error: ${validation.error}`); + this.exitHandler.exit(1); + } + + // Show warnings if any + if (validation.warnings) { + for (const warning of validation.warnings) { + this.output.warn(`Warning: ${warning}`); + } + } + } +} + +/** + * Create and run CLI application + */ +export async function runCli(argv: string[], deps?: Partial): Promise { + const app = new CliApplication(deps); + await app.run(argv); +} diff --git a/src/cli/arg-parser.ts b/src/cli/arg-parser.ts new file mode 100644 index 0000000..6980217 --- /dev/null +++ b/src/cli/arg-parser.ts @@ -0,0 +1,209 @@ +/** + * CLI Argument Parser + * + * Single Responsibility: Parse command line arguments into a structured object. + * Follows Open/Closed Principle with extensible argument definitions. + */ + +import type { CliArgs } from './types.js'; +import { CLI_DEFAULTS } from './types.js'; + +/** + * Argument definition for extensibility + */ +interface ArgDefinition { + flags: string[]; + hasValue: boolean; + handler: (result: CliArgs, value?: string) => void; +} + +/** + * Create argument definitions + * Open for extension - add new arguments by adding to this array + */ +function createArgDefinitions(): ArgDefinition[] { + return [ + // Boolean flags + { + flags: ['-h', '--help'], + hasValue: false, + handler: (result) => { + result.help = true; + }, + }, + { + flags: ['-v', '--version'], + hasValue: false, + handler: (result) => { + result.version = true; + }, + }, + { + flags: ['-y', '--yes', '--skip-confirmation'], + hasValue: false, + handler: (result) => { + result.skipConfirmation = true; + }, + }, + { + flags: ['--verbose'], + hasValue: false, + handler: (result) => { + result.verbose = true; + }, + }, + { + flags: ['--dry-run'], + hasValue: false, + handler: (result) => { + result.dryRun = true; + }, + }, + { + flags: ['--no-logs'], + hasValue: false, + handler: (result) => { + result.noLogs = true; + }, + }, + { + flags: ['-w', '--watch'], + hasValue: false, + handler: (result) => { + result.watch = true; + }, + }, + + // Value arguments + { + flags: ['-d', '--directory', '--sql-directory'], + hasValue: true, + handler: (result, value) => { + if (value) result.sqlDirectory = value; + }, + }, + { + flags: ['-u', '--url', '--database-url'], + hasValue: true, + handler: (result, value) => { + if (value) result.databaseUrl = value; + }, + }, + { + flags: ['-e', '--env', '--env-file'], + hasValue: true, + handler: (result, value) => { + if (value) result.envFile = value; + }, + }, + { + flags: ['--confirmation-phrase'], + hasValue: true, + handler: (result, value) => { + if (value) result.confirmationPhrase = value; + }, + }, + { + flags: ['--log-directory'], + hasValue: true, + handler: (result, value) => { + if (value) result.logDirectory = value; + }, + }, + { + flags: ['--only'], + hasValue: true, + handler: (result, value) => { + if (value) { + result.onlyFiles = parseCommaSeparated(value); + } + }, + }, + { + flags: ['--skip'], + hasValue: true, + handler: (result, value) => { + if (value) { + result.skipFiles = parseCommaSeparated(value); + } + }, + }, + ]; +} + +/** + * Parse comma-separated values into array + */ +function parseCommaSeparated(value: string): string[] { + return value.split(',').map((item) => item.trim()); +} + +/** + * Argument Parser class + */ +export class ArgParser { + private definitions: ArgDefinition[]; + private flagMap: Map; + + constructor() { + this.definitions = createArgDefinitions(); + this.flagMap = this.buildFlagMap(); + } + + /** + * Build a map from flags to definitions for O(1) lookup + */ + private buildFlagMap(): Map { + const map = new Map(); + for (const def of this.definitions) { + for (const flag of def.flags) { + map.set(flag, def); + } + } + return map; + } + + /** + * Parse command line arguments + */ + parse(args: string[]): CliArgs { + const result: CliArgs = { ...CLI_DEFAULTS }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const definition = this.flagMap.get(arg); + + if (definition) { + if (definition.hasValue) { + const nextArg = args[i + 1]; + definition.handler(result, nextArg); + if (nextArg && !nextArg.startsWith('-')) { + i++; + } + } else { + definition.handler(result); + } + } else if (!arg.startsWith('-')) { + // Positional argument - treat as directory + result.sqlDirectory = arg; + } + } + + return result; + } + + /** + * Get all supported flags (for help/documentation) + */ + getSupportedFlags(): string[] { + return Array.from(this.flagMap.keys()); + } +} + +/** + * Convenience function for simple usage + */ +export function parseArgs(args: string[]): CliArgs { + const parser = new ArgParser(); + return parser.parse(args); +} diff --git a/src/cli/cli.ts b/src/cli/cli.ts new file mode 100644 index 0000000..116a07e --- /dev/null +++ b/src/cli/cli.ts @@ -0,0 +1,13 @@ +#!/usr/bin/env node + +/** + * Supabase SQL Dev Runner CLI + * + * Entry point for the CLI application. + * This file is intentionally minimal - all logic is delegated to the application module. + */ + +import { runCli } from './application.js'; + +// Run CLI with process arguments +runCli(process.argv.slice(2)); diff --git a/src/cli/config-loader.ts b/src/cli/config-loader.ts new file mode 100644 index 0000000..61e17e2 --- /dev/null +++ b/src/cli/config-loader.ts @@ -0,0 +1,194 @@ +/** + * Configuration File Loader + * + * Uses cosmiconfig to load configuration from various sources: + * - package.json "sql-runner" field + * - .sql-runnerrc (JSON or YAML) + * - .sql-runnerrc.json + * - .sql-runnerrc.yaml / .sql-runnerrc.yml + * - .sql-runnerrc.js / .sql-runnerrc.cjs / .sql-runnerrc.mjs + * - sql-runner.config.js / sql-runner.config.cjs / sql-runner.config.mjs + * - sql-runner.config.ts (with TypeScript loader) + */ + +import { cosmiconfig } from 'cosmiconfig'; +import type { ConfigFileSchema, ConfigLoadResult } from './config-schema.js'; +import { CONFIG_MODULE_NAME } from './config-schema.js'; + +/** + * ConfigLoader class + * Handles loading and caching of configuration files + */ +export class ConfigLoader { + private explorer: ReturnType; + private cachedResult: ConfigLoadResult | null = null; + + constructor() { + this.explorer = cosmiconfig(CONFIG_MODULE_NAME, { + searchPlaces: [ + 'package.json', + `.${CONFIG_MODULE_NAME}rc`, + `.${CONFIG_MODULE_NAME}rc.json`, + `.${CONFIG_MODULE_NAME}rc.yaml`, + `.${CONFIG_MODULE_NAME}rc.yml`, + `.${CONFIG_MODULE_NAME}rc.js`, + `.${CONFIG_MODULE_NAME}rc.cjs`, + `.${CONFIG_MODULE_NAME}rc.mjs`, + `${CONFIG_MODULE_NAME}.config.js`, + `${CONFIG_MODULE_NAME}.config.cjs`, + `${CONFIG_MODULE_NAME}.config.mjs`, + `${CONFIG_MODULE_NAME}.config.ts`, + ], + }); + } + + /** + * Search for and load configuration file + * Searches from the current working directory upward + */ + async load(searchFrom?: string): Promise { + if (this.cachedResult) { + return this.cachedResult; + } + + try { + const result = await this.explorer.search(searchFrom); + + if (result && !result.isEmpty) { + this.cachedResult = { + config: this.validateAndNormalize(result.config), + filepath: result.filepath, + found: true, + }; + } else { + this.cachedResult = { + config: {}, + found: false, + }; + } + } catch (error) { + // If there's an error loading the config, log it and return empty config + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`Warning: Error loading config file: ${errorMessage}`); + this.cachedResult = { + config: {}, + found: false, + }; + } + + return this.cachedResult; + } + + /** + * Load configuration from a specific file path + */ + async loadFromPath(filepath: string): Promise { + try { + const result = await this.explorer.load(filepath); + + if (result && !result.isEmpty) { + return { + config: this.validateAndNormalize(result.config), + filepath: result.filepath, + found: true, + }; + } + + return { + config: {}, + found: false, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to load config from ${filepath}: ${errorMessage}`); + } + } + + /** + * Clear the cached configuration + */ + clearCache(): void { + this.cachedResult = null; + this.explorer.clearCaches(); + } + + /** + * Validate and normalize the loaded configuration + */ + private validateAndNormalize(config: unknown): ConfigFileSchema { + if (typeof config !== 'object' || config === null) { + return {}; + } + + const raw = config as Record; + const normalized: ConfigFileSchema = {}; + + // String properties + if (typeof raw.directory === 'string') { + normalized.directory = raw.directory; + } + if (typeof raw.databaseUrl === 'string') { + normalized.databaseUrl = raw.databaseUrl; + } + if (typeof raw.envFile === 'string') { + normalized.envFile = raw.envFile; + } + if (typeof raw.confirmationPhrase === 'string') { + normalized.confirmationPhrase = raw.confirmationPhrase; + } + if (typeof raw.logDirectory === 'string') { + normalized.logDirectory = raw.logDirectory; + } + if (typeof raw.filePattern === 'string') { + normalized.filePattern = raw.filePattern; + } + if (typeof raw.ignorePattern === 'string') { + normalized.ignorePattern = raw.ignorePattern; + } + + // Boolean properties + if (typeof raw.yes === 'boolean') { + normalized.yes = raw.yes; + } + if (typeof raw.verbose === 'boolean') { + normalized.verbose = raw.verbose; + } + if (typeof raw.dryRun === 'boolean') { + normalized.dryRun = raw.dryRun; + } + if (typeof raw.noLogs === 'boolean') { + normalized.noLogs = raw.noLogs; + } + if (typeof raw.watch === 'boolean') { + normalized.watch = raw.watch; + } + if (typeof raw.ssl === 'boolean') { + normalized.ssl = raw.ssl; + } + + // Array properties + if (Array.isArray(raw.only)) { + normalized.only = raw.only.filter((item): item is string => typeof item === 'string'); + } + if (Array.isArray(raw.skip)) { + normalized.skip = raw.skip.filter((item): item is string => typeof item === 'string'); + } + + return normalized; + } +} + +/** + * Create a new ConfigLoader instance + */ +export function createConfigLoader(): ConfigLoader { + return new ConfigLoader(); +} + +/** + * Convenience function to load configuration + */ +export async function loadConfig(searchFrom?: string): Promise { + const loader = createConfigLoader(); + return loader.load(searchFrom); +} diff --git a/src/cli/config-merger.ts b/src/cli/config-merger.ts new file mode 100644 index 0000000..7e66f84 --- /dev/null +++ b/src/cli/config-merger.ts @@ -0,0 +1,172 @@ +/** + * Configuration Merger + * + * Merges configuration from multiple sources with proper priority: + * 1. CLI arguments (highest priority) + * 2. Configuration file + * 3. Environment variables + * 4. Default values (lowest priority) + */ + +import type { CliArgs } from './types.js'; +import { CLI_DEFAULTS } from './types.js'; +import type { ConfigFileSchema } from './config-schema.js'; + +/** + * Merged configuration result + */ +export interface MergedConfig extends CliArgs { + /** Whether a config file was found and used */ + configFileUsed: boolean; + /** Path to the config file (if found) */ + configFilePath?: string; + /** SSL setting from config file */ + ssl?: boolean; + /** File pattern regex string */ + filePattern?: string; + /** Ignore pattern regex string */ + ignorePattern?: string; +} + +/** + * ConfigMerger class + * Combines configuration from multiple sources + */ +export class ConfigMerger { + /** + * Merge configuration from all sources + * + * Priority (highest to lowest): + * 1. CLI arguments + * 2. Config file + * 3. Defaults + */ + merge( + cliArgs: CliArgs, + fileConfig: ConfigFileSchema, + configFilePath?: string + ): MergedConfig { + // Start with defaults + const result: MergedConfig = { + ...CLI_DEFAULTS, + configFileUsed: false, + }; + + // Apply config file values (if present) + if (Object.keys(fileConfig).length > 0) { + result.configFileUsed = true; + result.configFilePath = configFilePath; + + // Map config file properties to CLI args + if (fileConfig.directory !== undefined) { + result.sqlDirectory = fileConfig.directory; + } + if (fileConfig.databaseUrl !== undefined) { + result.databaseUrl = fileConfig.databaseUrl; + } + if (fileConfig.envFile !== undefined) { + result.envFile = fileConfig.envFile; + } + if (fileConfig.yes !== undefined) { + result.skipConfirmation = fileConfig.yes; + } + if (fileConfig.confirmationPhrase !== undefined) { + result.confirmationPhrase = fileConfig.confirmationPhrase; + } + if (fileConfig.verbose !== undefined) { + result.verbose = fileConfig.verbose; + } + if (fileConfig.dryRun !== undefined) { + result.dryRun = fileConfig.dryRun; + } + if (fileConfig.noLogs !== undefined) { + result.noLogs = fileConfig.noLogs; + } + if (fileConfig.logDirectory !== undefined) { + result.logDirectory = fileConfig.logDirectory; + } + if (fileConfig.only !== undefined) { + result.onlyFiles = fileConfig.only; + } + if (fileConfig.skip !== undefined) { + result.skipFiles = fileConfig.skip; + } + if (fileConfig.watch !== undefined) { + result.watch = fileConfig.watch; + } + if (fileConfig.ssl !== undefined) { + result.ssl = fileConfig.ssl; + } + if (fileConfig.filePattern !== undefined) { + result.filePattern = fileConfig.filePattern; + } + if (fileConfig.ignorePattern !== undefined) { + result.ignorePattern = fileConfig.ignorePattern; + } + } + + // Apply CLI arguments (override config file) + // Only override if the CLI value differs from default (was explicitly set) + if (cliArgs.sqlDirectory !== CLI_DEFAULTS.sqlDirectory) { + result.sqlDirectory = cliArgs.sqlDirectory; + } + if (cliArgs.databaseUrl !== undefined) { + result.databaseUrl = cliArgs.databaseUrl; + } + if (cliArgs.envFile !== undefined) { + result.envFile = cliArgs.envFile; + } + if (cliArgs.skipConfirmation !== CLI_DEFAULTS.skipConfirmation) { + result.skipConfirmation = cliArgs.skipConfirmation; + } + if (cliArgs.confirmationPhrase !== CLI_DEFAULTS.confirmationPhrase) { + result.confirmationPhrase = cliArgs.confirmationPhrase; + } + if (cliArgs.verbose !== CLI_DEFAULTS.verbose) { + result.verbose = cliArgs.verbose; + } + if (cliArgs.dryRun !== CLI_DEFAULTS.dryRun) { + result.dryRun = cliArgs.dryRun; + } + if (cliArgs.noLogs !== CLI_DEFAULTS.noLogs) { + result.noLogs = cliArgs.noLogs; + } + if (cliArgs.logDirectory !== CLI_DEFAULTS.logDirectory) { + result.logDirectory = cliArgs.logDirectory; + } + if (cliArgs.onlyFiles !== undefined) { + result.onlyFiles = cliArgs.onlyFiles; + } + if (cliArgs.skipFiles !== undefined) { + result.skipFiles = cliArgs.skipFiles; + } + if (cliArgs.watch !== CLI_DEFAULTS.watch) { + result.watch = cliArgs.watch; + } + + // Preserve help and version flags from CLI + result.help = cliArgs.help; + result.version = cliArgs.version; + + return result; + } +} + +/** + * Create a new ConfigMerger instance + */ +export function createConfigMerger(): ConfigMerger { + return new ConfigMerger(); +} + +/** + * Convenience function to merge configuration + */ +export function mergeConfig( + cliArgs: CliArgs, + fileConfig: ConfigFileSchema, + configFilePath?: string +): MergedConfig { + const merger = createConfigMerger(); + return merger.merge(cliArgs, fileConfig, configFilePath); +} diff --git a/src/cli/config-schema.ts b/src/cli/config-schema.ts new file mode 100644 index 0000000..2c2bf41 --- /dev/null +++ b/src/cli/config-schema.ts @@ -0,0 +1,145 @@ +/** + * Configuration File Schema + * + * Defines the structure for configuration files (.sql-runnerrc, sql-runner.config.js, etc.) + * This is the user-facing configuration schema that gets merged with CLI args. + */ + +/** + * Configuration file schema + * All properties are optional - users only specify what they want to override + */ +export interface ConfigFileSchema { + /** + * Directory containing SQL files to execute + * @default "./sql" + * @example "./database/scripts" + */ + directory?: string; + + /** + * Database URL (can also use DATABASE_URL env var) + * @example "postgres://user:pass@host:5432/db" + */ + databaseUrl?: string; + + /** + * Path to .env file + * @default ".env" + */ + envFile?: string; + + /** + * Skip confirmation prompt + * @default false + */ + yes?: boolean; + + /** + * Custom confirmation phrase + * @default "CONFIRM" + */ + confirmationPhrase?: string; + + /** + * Enable verbose output + * @default false + */ + verbose?: boolean; + + /** + * Dry run mode - preview without executing + * @default false + */ + dryRun?: boolean; + + /** + * Disable file logging + * @default false + */ + noLogs?: boolean; + + /** + * Directory for log files + * @default "./logs" + */ + logDirectory?: string; + + /** + * Only run specific files (by name) + * @example ["01_tables.sql", "02_functions.sql"] + */ + only?: string[]; + + /** + * Skip specific files (by name) + * @example ["06_seed.sql"] + */ + skip?: string[]; + + /** + * Watch mode - re-run on file changes + * @default false + */ + watch?: boolean; + + /** + * Enable SSL for database connection + * @default true + */ + ssl?: boolean; + + /** + * File pattern to match SQL files (regex string) + * @default "\\.sql$" + */ + filePattern?: string; + + /** + * Pattern for files to ignore (regex string) + * @default "^_ignored|README" + */ + ignorePattern?: string; +} + +/** + * Result of loading a configuration file + */ +export interface ConfigLoadResult { + /** The loaded configuration (empty object if no config found) */ + config: ConfigFileSchema; + /** Path to the config file that was loaded (undefined if none found) */ + filepath?: string; + /** Whether a config file was found */ + found: boolean; +} + +/** + * Module name used for cosmiconfig search + * This determines the config file names: .sql-runnerrc, sql-runner.config.js, etc. + */ +export const CONFIG_MODULE_NAME = 'sql-runner'; + +/** + * Default configuration values + */ +export const CONFIG_DEFAULTS: Required< + Omit +> & { + databaseUrl?: string; + only?: string[]; + skip?: string[]; +} = { + directory: './sql', + envFile: '.env', + yes: false, + confirmationPhrase: 'CONFIRM', + verbose: false, + dryRun: false, + noLogs: false, + logDirectory: './logs', + watch: false, + ssl: true, + filePattern: '\\.sql$', + ignorePattern: '^_ignored|README', +}; diff --git a/src/cli/env-loader.ts b/src/cli/env-loader.ts new file mode 100644 index 0000000..74840f6 --- /dev/null +++ b/src/cli/env-loader.ts @@ -0,0 +1,150 @@ +/** + * Environment Loader + * + * Single Responsibility: Load environment variables from files. + * Follows Dependency Inversion with injectable file system and process env. + */ + +import * as fs from 'node:fs'; +import type { FileSystem, ProcessEnv, EnvLoadResult } from './types.js'; + +/** + * Default file system implementation + */ +export const defaultFileSystem: FileSystem = { + exists: (path: string) => fs.existsSync(path), + readFile: (path: string) => fs.readFileSync(path, 'utf8'), +}; + +/** + * Default process env implementation + */ +export const defaultProcessEnv: ProcessEnv = { + get: (key: string) => process.env[key], + set: (key: string, value: string) => { + process.env[key] = value; + }, +}; + +/** + * Parse a single line from an env file + */ +function parseLine(line: string): { key: string; value: string } | null { + const trimmed = line.trim(); + + // Skip comments and empty lines + if (!trimmed || trimmed.startsWith('#')) { + return null; + } + + const equalIndex = trimmed.indexOf('='); + if (equalIndex <= 0) { + return null; + } + + const key = trimmed.slice(0, equalIndex).trim(); + let value = trimmed.slice(equalIndex + 1).trim(); + + // Remove surrounding quotes if present + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + return { key, value }; +} + +/** + * Parse env file content into key-value pairs + */ +export function parseEnvContent(content: string): Map { + const result = new Map(); + const lines = content.split('\n'); + + for (const line of lines) { + const parsed = parseLine(line); + if (parsed) { + result.set(parsed.key, parsed.value); + } + } + + return result; +} + +/** + * Environment Loader class + */ +export class EnvLoader { + constructor( + private fileSystem: FileSystem = defaultFileSystem, + private processEnv: ProcessEnv = defaultProcessEnv + ) {} + + /** + * Load environment variables from a file + * Only sets variables that are not already set in the environment + * + * @param envPath - Path to the env file + * @returns Result indicating success/failure and loaded keys + */ + load(envPath: string): EnvLoadResult { + if (!this.fileSystem.exists(envPath)) { + return { + success: false, + error: `Environment file not found: ${envPath}`, + }; + } + + try { + const content = this.fileSystem.readFile(envPath); + const envVars = parseEnvContent(content); + const loadedKeys: string[] = []; + + for (const [key, value] of envVars) { + // Only set if not already set (environment takes precedence) + if (this.processEnv.get(key) === undefined) { + this.processEnv.set(key, value); + loadedKeys.push(key); + } + } + + return { + success: true, + loadedKeys, + }; + } catch (error) { + return { + success: false, + error: `Failed to read env file: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + /** + * Check if an env file exists + */ + exists(envPath: string): boolean { + return this.fileSystem.exists(envPath); + } + + /** + * Get an environment variable + */ + get(key: string): string | undefined { + return this.processEnv.get(key); + } +} + +/** + * Convenience function for simple usage + */ +export function loadEnvFile( + envPath: string, + fileSystem: FileSystem = defaultFileSystem, + processEnv: ProcessEnv = defaultProcessEnv +): EnvLoadResult { + const loader = new EnvLoader(fileSystem, processEnv); + return loader.load(envPath); +} diff --git a/src/cli/executor.ts b/src/cli/executor.ts new file mode 100644 index 0000000..4fd6e8c --- /dev/null +++ b/src/cli/executor.ts @@ -0,0 +1,190 @@ +/** + * CLI Runner Executor + * + * Single Responsibility: Execute SQL runner operations. + * Handles running, watching, and error handling for CLI operations. + */ + +import { SqlRunner } from '../core/runner.js'; +import { startWatcher } from '../core/watcher.js'; +import { getConnectionErrorHelp, formatConnectionErrorHelp } from '../core/connection.js'; +import type { SqlRunnerConfig, ExecutionSummary, RunOptions } from '../types.js'; +import type { CliArgs, CliOutput } from './types.js'; +import type { MergedConfig } from './config-merger.js'; +import { defaultCliOutput } from './help.js'; +import { c, symbols } from '../ui/index.js'; + +/** + * Configuration for the executor + * Accepts either CliArgs or MergedConfig (which extends CliArgs with config file settings) + */ +export interface ExecutorConfig { + databaseUrl: string; + sqlDirectory: string; + args: CliArgs | MergedConfig; +} + +/** + * Execution result + */ +export interface ExecutionResult { + success: boolean; + summary?: ExecutionSummary; + error?: string; + exitCode: number; +} + +/** + * Build SqlRunnerConfig from CLI args + */ +export function buildRunnerConfig(config: ExecutorConfig): SqlRunnerConfig { + const { databaseUrl, sqlDirectory, args } = config; + + const runnerConfig: SqlRunnerConfig = { + databaseUrl, + sqlDirectory, + requireConfirmation: !args.skipConfirmation, + confirmationPhrase: args.confirmationPhrase, + verbose: args.verbose, + logDirectory: args.noLogs ? null : args.logDirectory, + }; + + // Apply additional config file settings if available (MergedConfig) + const mergedArgs = args as MergedConfig; + if (mergedArgs.ssl !== undefined) { + runnerConfig.ssl = mergedArgs.ssl; + } + if (mergedArgs.filePattern !== undefined) { + runnerConfig.filePattern = new RegExp(mergedArgs.filePattern); + } + if (mergedArgs.ignorePattern !== undefined) { + runnerConfig.ignorePattern = new RegExp(mergedArgs.ignorePattern); + } + + return runnerConfig; +} + +/** + * Build RunOptions from CLI args + */ +export function buildRunOptions(args: CliArgs, skipConfirmation?: boolean): RunOptions { + return { + skipConfirmation: skipConfirmation ?? args.skipConfirmation, + onlyFiles: args.onlyFiles, + skipFiles: args.skipFiles, + dryRun: args.dryRun, + }; +} + +/** + * CLI Executor class + */ +export class CliExecutor { + private output: CliOutput; + + constructor(output: CliOutput = defaultCliOutput) { + this.output = output; + } + + /** + * Execute SQL scripts + */ + async execute(config: ExecutorConfig): Promise { + const runnerConfig = buildRunnerConfig(config); + const runOptions = buildRunOptions(config.args); + + try { + const runner = new SqlRunner(runnerConfig); + const summary = await runner.run(runOptions); + + return { + success: summary.allSuccessful, + summary, + exitCode: summary.allSuccessful ? 0 : 1, + }; + } catch (error) { + return this.handleError(error, config.databaseUrl); + } + } + + /** + * Execute with watch mode + */ + async executeWithWatch( + config: ExecutorConfig, + onCleanup?: () => void + ): Promise { + const runnerConfig = buildRunnerConfig(config); + const runOptions = buildRunOptions(config.args); + + try { + // Initial run + const runner = new SqlRunner(runnerConfig); + const summary = await runner.run(runOptions); + + // Start watching + this.output.log(''); + this.output.log(`${c.primary(symbols.running)} ${c.primary('Watching for changes...')} ${c.muted('(Ctrl+C to stop)')}`); + + const cleanup = startWatcher({ + directory: config.sqlDirectory, + pattern: /\.sql$/, + countdownSeconds: 30, + onExecute: async () => { + const watchRunner = new SqlRunner(runnerConfig); + await watchRunner.run(buildRunOptions(config.args, true)); + }, + logger: { + info: (msg) => this.output.log(`${c.info(symbols.info)} ${msg}`), + warning: (msg) => this.output.warn(`${c.warning(symbols.warning)} ${msg}`), + }, + }); + + // Setup cleanup handler + this.setupWatchCleanup(cleanup, onCleanup); + + return { + success: true, + summary, + exitCode: 0, + }; + } catch (error) { + return this.handleError(error, config.databaseUrl); + } + } + + /** + * Setup watch mode cleanup handlers + */ + private setupWatchCleanup(cleanup: () => void, onCleanup?: () => void): void { + process.on('SIGINT', () => { + this.output.log(''); + this.output.log(`${c.muted(symbols.info)} Stopped watching.`); + cleanup(); + onCleanup?.(); + process.exit(0); + }); + + // Keep process alive + process.stdin.resume(); + } + + /** + * Handle execution errors + */ + private handleError(error: unknown, databaseUrl: string): ExecutionResult { + const errorHelp = getConnectionErrorHelp(error, databaseUrl); + + if (errorHelp.isKnownError) { + this.output.error(formatConnectionErrorHelp(errorHelp)); + } else { + this.output.error(`${c.error(symbols.error)} Fatal error: ${error instanceof Error ? error.message : String(error)}`); + } + + return { + success: false, + error: error instanceof Error ? error.message : String(error), + exitCode: 1, + }; + } +} diff --git a/src/cli/help.ts b/src/cli/help.ts new file mode 100644 index 0000000..882c4b1 --- /dev/null +++ b/src/cli/help.ts @@ -0,0 +1,189 @@ +/** + * Help and Version Display + * + * Single Responsibility: Display help and version information. + * Follows Dependency Inversion with injectable output and file system. + */ + +import * as path from 'node:path'; +import * as url from 'node:url'; +import type { CliOutput, FileSystem } from './types.js'; +import { defaultFileSystem } from './env-loader.js'; +import { c, symbols, renderBanner } from '../ui/index.js'; + +/** + * Default CLI output implementation + */ +export const defaultCliOutput: CliOutput = { + log: (message: string) => console.log(message), + error: (message: string) => console.error(message), + warn: (message: string) => console.warn(message), +}; + +/** + * Package info for version display + */ +interface PackageInfo { + name: string; + version: string; +} + +/** + * Default package info fallback + */ +const DEFAULT_PACKAGE_INFO: PackageInfo = { + name: 'sql-runner', + version: '1.0.0', +}; + +/** + * Generate styled help text + */ +function generateHelpText(version: string): string { + return ` +${renderBanner({ name: 'sql-runner', version, subtitle: 'Supabase SQL Dev Runner' })} + +${c.warning(symbols.warning)} ${c.warning('Development tool')} ${c.muted('- not for production use')} + +${c.title('Usage')} + sql-runner [options] [directory] + +${c.title('Arguments')} + ${c.muted('directory')} SQL directory ${c.muted('(default: ./sql)')} + +${c.title('Options')} + ${c.cyan('-h, --help')} Show this help message + ${c.cyan('-v, --version')} Show version number + + ${c.cyan('-d, --directory')} SQL files directory + ${c.cyan('-u, --database-url')} Database URL (or use DATABASE_URL env) + ${c.cyan('-e, --env-file')} Load environment from file ${c.muted('(default: .env)')} + + ${c.cyan('-y, --yes')} Skip confirmation prompt + ${c.cyan('--confirmation-phrase')} Custom confirmation phrase ${c.muted('(default: CONFIRM)')} + + ${c.cyan('--verbose')} Enable verbose output + ${c.cyan('--dry-run')} Show what would be executed + ${c.cyan('--no-logs')} Disable file logging + ${c.cyan('--log-directory')} Log directory ${c.muted('(default: ./logs)')} + + ${c.cyan('--only')} Only run specific files ${c.muted('(comma-separated)')} + ${c.cyan('--skip')} Skip specific files ${c.muted('(comma-separated)')} + + ${c.cyan('-w, --watch')} Watch for file changes and re-run + +${c.title('Environment')} + ${c.muted('DATABASE_URL')} PostgreSQL connection string + +${c.title('Examples')} + ${c.muted('$')} sql-runner ${c.muted('# Run all SQL files')} + ${c.muted('$')} sql-runner ./migrations ${c.muted('# Custom directory')} + ${c.muted('$')} sql-runner -y ${c.muted('# Skip confirmation')} + ${c.muted('$')} sql-runner --dry-run ${c.muted('# Preview mode')} + ${c.muted('$')} sql-runner --only "01_tables.sql" ${c.muted('# Run specific file')} + ${c.muted('$')} sql-runner --skip "06_seed.sql" ${c.muted('# Skip specific file')} + ${c.muted('$')} sql-runner --watch ${c.muted('# Watch mode')} +`; +} + +/** + * Help Display class + */ +export class HelpDisplay { + constructor( + private output: CliOutput = defaultCliOutput, + private fileSystem: FileSystem = defaultFileSystem + ) {} + + /** + * Display help message + */ + showHelp(): void { + const info = this.getPackageInfo(); + this.output.log(generateHelpText(info.version)); + } + + /** + * Display version number + */ + showVersion(): void { + const info = this.getPackageInfo(); + this.output.log(`${c.primary(symbols.arrowRight)} ${c.title(info.name)} ${c.muted(`v${info.version}`)}`); + } + + /** + * Get package info from package.json + */ + private getPackageInfo(): PackageInfo { + try { + // Try multiple paths to find package.json + const possiblePaths = this.getPackageJsonPaths(); + + for (const packagePath of possiblePaths) { + if (this.fileSystem.exists(packagePath)) { + const content = this.fileSystem.readFile(packagePath); + const packageJson = JSON.parse(content); + return { + name: packageJson.name || DEFAULT_PACKAGE_INFO.name, + version: packageJson.version || DEFAULT_PACKAGE_INFO.version, + }; + } + } + } catch { + // Fall through to default + } + + return DEFAULT_PACKAGE_INFO; + } + + /** + * Get possible paths to package.json + */ + private getPackageJsonPaths(): string[] { + const paths: string[] = []; + + // Try __dirname-based paths (for CJS) + if (typeof __dirname !== 'undefined') { + paths.push(path.join(__dirname, '..', '..', 'package.json')); + paths.push(path.join(__dirname, '..', 'package.json')); + } + + // Try import.meta.url-based paths (for ESM) + try { + const currentFile = url.fileURLToPath(import.meta.url); + const currentDir = path.dirname(currentFile); + paths.push(path.join(currentDir, '..', '..', 'package.json')); + paths.push(path.join(currentDir, '..', 'package.json')); + } catch { + // import.meta not available + } + + // Try cwd-based path + paths.push(path.join(process.cwd(), 'package.json')); + + return paths; + } +} + +/** + * Convenience functions for simple usage + */ +export function showHelp(output: CliOutput = defaultCliOutput): void { + const display = new HelpDisplay(output); + display.showHelp(); +} + +export function showVersion( + output: CliOutput = defaultCliOutput, + fileSystem: FileSystem = defaultFileSystem +): void { + const display = new HelpDisplay(output, fileSystem); + display.showVersion(); +} + +/** + * Get the help text (for testing) + */ +export function getHelpText(): string { + return generateHelpText(DEFAULT_PACKAGE_INFO.version); +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..d37081a --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,86 @@ +/** + * CLI Module Exports + * + * Public API for the CLI module. + */ + +// Types +export type { + CliArgs, + CliOutput, + FileSystem, + ProcessEnv, + ExitHandler, + ValidationResult, + EnvLoadResult, +} from './types.js'; + +export { CLI_DEFAULTS } from './types.js'; + +// Argument Parser +export { ArgParser, parseArgs } from './arg-parser.js'; + +// Environment Loader +export { + EnvLoader, + loadEnvFile, + parseEnvContent, + defaultFileSystem, + defaultProcessEnv, +} from './env-loader.js'; + +// Validators +export { + CliValidator, + DatabaseUrlValidator, + SqlDirectoryValidator, + EnvFileValidator, + ArgumentCombinationValidator, +} from './validators.js'; + +export type { Validator } from './validators.js'; + +// Help Display +export { + HelpDisplay, + showHelp, + showVersion, + getHelpText, + defaultCliOutput, +} from './help.js'; + +// Configuration +export { + ConfigLoader, + createConfigLoader, + loadConfig, +} from './config-loader.js'; + +export type { ConfigFileSchema, ConfigLoadResult } from './config-schema.js'; +export { CONFIG_MODULE_NAME, CONFIG_DEFAULTS } from './config-schema.js'; + +export { + ConfigMerger, + createConfigMerger, + mergeConfig, +} from './config-merger.js'; + +export type { MergedConfig } from './config-merger.js'; + +// Executor +export { + CliExecutor, + buildRunnerConfig, + buildRunOptions, +} from './executor.js'; + +export type { ExecutorConfig, ExecutionResult } from './executor.js'; + +// Application +export { + CliApplication, + runCli, + createDefaultDependencies, +} from './application.js'; + +export type { CliDependencies } from './application.js'; diff --git a/src/cli/types.ts b/src/cli/types.ts new file mode 100644 index 0000000..a18968a --- /dev/null +++ b/src/cli/types.ts @@ -0,0 +1,91 @@ +/** + * CLI Types and Interfaces + * + * Defines the contracts for CLI components following Interface Segregation Principle. + */ + +/** + * Parsed CLI arguments + */ +export interface CliArgs { + sqlDirectory: string; + databaseUrl?: string; + envFile?: string; + skipConfirmation: boolean; + confirmationPhrase: string; + verbose: boolean; + dryRun: boolean; + noLogs: boolean; + logDirectory: string; + onlyFiles?: string[]; + skipFiles?: string[]; + watch: boolean; + help: boolean; + version: boolean; +} + +/** + * Default values for CLI arguments + */ +export const CLI_DEFAULTS: Readonly = Object.freeze({ + sqlDirectory: './sql', + skipConfirmation: false, + confirmationPhrase: 'CONFIRM', + verbose: false, + dryRun: false, + noLogs: false, + logDirectory: './logs', + watch: false, + help: false, + version: false, +}); + +/** + * CLI output interface for dependency injection + */ +export interface CliOutput { + log(message: string): void; + error(message: string): void; + warn(message: string): void; +} + +/** + * File system abstraction for testing + */ +export interface FileSystem { + exists(path: string): boolean; + readFile(path: string): string; +} + +/** + * Process abstraction for testing + */ +export interface ProcessEnv { + get(key: string): string | undefined; + set(key: string, value: string): void; +} + +/** + * Exit handler abstraction + */ +export interface ExitHandler { + exit(code: number): never; +} + +/** + * Validation result + */ +export interface ValidationResult { + valid: boolean; + error?: string; + warnings?: string[]; +} + +/** + * Environment loading result + */ +export interface EnvLoadResult { + success: boolean; + error?: string; + loadedKeys?: string[]; +} diff --git a/src/cli/validators.ts b/src/cli/validators.ts new file mode 100644 index 0000000..b178cc4 --- /dev/null +++ b/src/cli/validators.ts @@ -0,0 +1,207 @@ +/** + * CLI Validators + * + * Single Responsibility: Validate CLI inputs and configuration. + * Each validator handles one specific validation concern. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { CliArgs, FileSystem, ValidationResult } from './types.js'; +import { defaultFileSystem } from './env-loader.js'; + +/** + * Validator interface for extensibility + */ +export interface Validator { + validate(value: T): ValidationResult; +} + +/** + * Database URL validator + */ +export class DatabaseUrlValidator implements Validator { + validate(value: string | undefined): ValidationResult { + if (!value || value.trim() === '') { + return { + valid: false, + error: [ + 'DATABASE_URL is required.', + '', + 'Provide it via:', + ' - Environment variable: DATABASE_URL="postgres://..."', + ' - Command line: --database-url "postgres://..."', + ' - Env file: Create .env with DATABASE_URL="postgres://..."', + '', + 'Note: Wrap the URL in quotes if it contains special characters.', + '', + 'Run with --help for more information.', + ].join('\n'), + }; + } + + // Basic URL format validation + if (!value.startsWith('postgres://') && !value.startsWith('postgresql://')) { + return { + valid: false, + error: 'DATABASE_URL must start with postgres:// or postgresql://', + }; + } + + return { valid: true }; + } +} + +/** + * SQL directory validator + */ +export class SqlDirectoryValidator implements Validator { + constructor(private fileSystem: FileSystem = defaultFileSystem) {} + + validate(value: string): ValidationResult { + const resolvedPath = path.resolve(value); + + if (!this.fileSystem.exists(resolvedPath)) { + return { + valid: false, + error: `SQL directory not found: ${resolvedPath}`, + }; + } + + // Check if it's actually a directory + try { + const stats = fs.statSync(resolvedPath); + if (!stats.isDirectory()) { + return { + valid: false, + error: `Path is not a directory: ${resolvedPath}`, + }; + } + } catch { + return { + valid: false, + error: `Cannot access directory: ${resolvedPath}`, + }; + } + + return { valid: true }; + } + + /** + * Resolve the directory path + */ + resolve(value: string): string { + return path.resolve(value); + } +} + +/** + * Env file validator + */ +export class EnvFileValidator implements Validator { + constructor(private fileSystem: FileSystem = defaultFileSystem) {} + + validate(value: string | undefined): ValidationResult { + // If no env file specified, it's valid (will use default or skip) + if (!value) { + return { valid: true }; + } + + if (!this.fileSystem.exists(value)) { + return { + valid: false, + error: `Specified env file not found: ${value}`, + }; + } + + return { valid: true }; + } +} + +/** + * Argument combination validator + * Checks for conflicting or unusual argument combinations + */ +export class ArgumentCombinationValidator implements Validator> { + validate(args: Partial): ValidationResult { + const warnings: string[] = []; + + // Check for --watch with --dry-run (contradictory) + if (args.watch && args.dryRun) { + return { + valid: false, + error: '--watch and --dry-run cannot be used together. Watch mode requires actual execution.', + }; + } + + // Check for --only with --skip (potentially confusing) + if (args.onlyFiles?.length && args.skipFiles?.length) { + warnings.push( + 'Both --only and --skip are specified. Files in --skip will be excluded from --only list.' + ); + } + + // Check for --watch with -y/--yes not specified (informational) + if (args.watch && !args.skipConfirmation) { + warnings.push( + 'Watch mode with confirmation prompt: first run will require confirmation, subsequent runs will auto-execute.' + ); + } + + return { + valid: true, + warnings: warnings.length > 0 ? warnings : undefined, + }; + } +} + +/** + * Composite validator for all CLI inputs + */ +export class CliValidator { + private databaseUrlValidator = new DatabaseUrlValidator(); + private sqlDirectoryValidator: SqlDirectoryValidator; + private envFileValidator: EnvFileValidator; + private argumentCombinationValidator = new ArgumentCombinationValidator(); + + constructor(fileSystem: FileSystem = defaultFileSystem) { + this.sqlDirectoryValidator = new SqlDirectoryValidator(fileSystem); + this.envFileValidator = new EnvFileValidator(fileSystem); + } + + /** + * Validate database URL + */ + validateDatabaseUrl(url: string | undefined): ValidationResult { + return this.databaseUrlValidator.validate(url); + } + + /** + * Validate SQL directory + */ + validateSqlDirectory(directory: string): ValidationResult { + return this.sqlDirectoryValidator.validate(directory); + } + + /** + * Validate env file + */ + validateEnvFile(envFile: string | undefined): ValidationResult { + return this.envFileValidator.validate(envFile); + } + + /** + * Resolve SQL directory path + */ + resolveSqlDirectory(directory: string): string { + return this.sqlDirectoryValidator.resolve(directory); + } + + /** + * Validate argument combinations + */ + validateArgumentCombinations(args: Partial): ValidationResult { + return this.argumentCombinationValidator.validate(args); + } +} + diff --git a/src/core/connection.ts b/src/core/connection.ts new file mode 100644 index 0000000..c38c745 --- /dev/null +++ b/src/core/connection.ts @@ -0,0 +1,140 @@ +/** + * Database connection utilities + * + * This module provides utilities for parsing, validating, and handling + * database connection strings and errors. + */ + +import type { ConnectionConfig } from '../types.js'; + +// Re-export from the new error handling system for backwards compatibility +export { + getConnectionErrorHelp, + formatConnectionErrorHelp, + ConnectionErrorHandler, +} from './errors/index.js'; + +// Re-export types for backwards compatibility +export type { ErrorHelp as ConnectionErrorHelp } from './errors/index.js'; + +/** + * Extracts a human-readable error message from an unknown error + * + * @param error - Unknown error value + * @returns Error message string + * + * @example + * ```ts + * try { + * await someOperation(); + * } catch (error) { + * console.error(getErrorMessage(error)); + * } + * ``` + */ +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +/** + * Parses a PostgreSQL/Supabase database URL into connection config + * + * @param databaseUrl - PostgreSQL connection URL + * @param sslOption - SSL configuration option + * @returns Parsed connection configuration + * @throws Error if URL format is invalid + * + * @example + * ```ts + * const config = parseDatabaseUrl( + * 'postgres://user:pass@host:5432/dbname' + * ); + * ``` + */ +export function parseDatabaseUrl( + databaseUrl: string, + sslOption: boolean | { rejectUnauthorized: boolean } = true +): ConnectionConfig { + try { + const url = new URL(databaseUrl); + + if (!url.hostname) { + throw new Error('Missing hostname in database URL'); + } + + if (!url.username) { + throw new Error('Missing username in database URL'); + } + + return { + host: url.hostname, + port: parseInt(url.port, 10) || 5432, + database: url.pathname.slice(1) || 'postgres', + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + ssl: sslOption === true ? { rejectUnauthorized: false } : sslOption, + }; + } catch (error) { + if (error instanceof TypeError) { + throw new Error( + `Invalid database URL format. Expected: postgres://user:password@host:port/database\nReceived: ${maskPassword(databaseUrl)}` + ); + } + throw error; + } +} + +/** + * Masks the password in a database URL for safe logging + * + * @param url - Database URL that may contain a password + * @returns URL with password replaced by '***' + * + * @example + * ```ts + * maskPassword('postgres://user:secret@localhost/db'); + * // Returns: 'postgres://user:***@localhost/db' + * ``` + */ +export function maskPassword(url: string): string { + try { + const parsed = new URL(url); + if (parsed.password) { + parsed.password = '***'; + } + return parsed.toString(); + } catch { + // If URL parsing fails, try regex replacement + return url.replace(/:([^@:]+)@/, ':***@'); + } +} + +/** + * Validates that a database URL is provided + * + * @param databaseUrl - Database URL to validate + * @returns The validated database URL + * @throws Error if database URL is undefined or empty + * + * @example + * ```ts + * const url = validateDatabaseUrl(process.env.DATABASE_URL); + * // Throws if DATABASE_URL is not set + * ``` + */ +export function validateDatabaseUrl(databaseUrl: string | undefined): string { + if (!databaseUrl) { + throw new Error( + 'DATABASE_URL is required.\n\n' + + 'Please provide it via:\n' + + ' - Environment variable: DATABASE_URL\n' + + ' - Config option: { databaseUrl: "postgres://..." }\n\n' + + 'Format: postgres://user:password@host:port/database' + ); + } + + return databaseUrl; +} diff --git a/src/core/errors/detectors/authentication-detector.ts b/src/core/errors/detectors/authentication-detector.ts new file mode 100644 index 0000000..2cf431b --- /dev/null +++ b/src/core/errors/detectors/authentication-detector.ts @@ -0,0 +1,92 @@ +/** + * Detector for authentication failures + * + * Occurs when username/password combination is rejected by the server. + */ + +import type { ErrorHelp, ConnectionContext } from '../types.js'; +import { BaseErrorDetector } from './base-detector.js'; + +export class AuthenticationDetector extends BaseErrorDetector { + readonly name = 'authentication'; + + canHandle(error: unknown, _context: ConnectionContext): boolean { + const errorMessage = this.getErrorMessage(error).toLowerCase(); + + return ( + errorMessage.includes('password authentication failed') || + errorMessage.includes('authentication failed') || + errorMessage.includes('invalid password') || + errorMessage.includes('no pg_hba.conf entry') || + errorMessage.includes('role') && errorMessage.includes('does not exist') + ); + } + + getHelp(error: unknown, context: ConnectionContext): ErrorHelp { + const errorMessage = this.getErrorMessage(error); + const isPooler = this.isPoolerConnection(context.databaseUrl); + const isDirectConnection = this.isDirectConnection(context.databaseUrl); + const projectRef = this.extractProjectRef(context.databaseUrl) || 'YOUR_PROJECT_REF'; + + const suggestions = [ + 'The database rejected the authentication credentials.', + '', + 'Possible causes and solutions:', + '', + '1. WRONG PASSWORD', + ' - Copy a fresh connection string from Supabase Dashboard > Connect', + ' - Make sure there are no extra spaces in the password', + ' - Check for special characters that may need URL encoding', + '', + '2. WRONG USERNAME FORMAT', + ]; + + if (isPooler) { + suggestions.push( + ` For Pooler connections, username should be: postgres.${projectRef}`, + ' (Note the dot between "postgres" and your project reference)' + ); + } else if (isDirectConnection) { + suggestions.push( + ' For Direct connections, username should be: postgres', + ' (Just "postgres", without the project reference)' + ); + } else { + suggestions.push( + ' For Supabase:', + ' - Pooler: postgres.PROJECT_REF', + ' - Direct: postgres' + ); + } + + suggestions.push( + '', + '3. SPECIAL CHARACTERS IN PASSWORD', + ' If your password contains special characters, they need URL encoding:', + ' - @ becomes %40', + ' - # becomes %23', + ' - : becomes %3A', + ' - / becomes %2F', + ' Or use a password without special characters.', + '', + '4. ENV FILE ISSUES', + ' Make sure your .env file is correct:', + ' - URL should be in quotes: DATABASE_URL="postgres://..."', + ' - No spaces around the = sign', + ' - No trailing spaces', + '', + '5. DATABASE USER NOT EXISTS', + ' - The database user may not exist', + ' - Try using the default "postgres" user connection string' + ); + + return this.createErrorHelp({ + title: 'Authentication Failed', + explanation: + 'The database rejected the username/password combination.\n' + + 'This usually means the credentials are incorrect.', + suggestions, + originalMessage: errorMessage, + }); + } +} diff --git a/src/core/errors/detectors/base-detector.ts b/src/core/errors/detectors/base-detector.ts new file mode 100644 index 0000000..6bad50b --- /dev/null +++ b/src/core/errors/detectors/base-detector.ts @@ -0,0 +1,105 @@ +/** + * Base class for error detectors + * Provides common utilities for error analysis + */ + +import type { ErrorDetector, ErrorHelp, ConnectionContext } from '../types.js'; + +/** + * Abstract base class for error detectors + * Provides utility methods for common error analysis tasks + */ +export abstract class BaseErrorDetector implements ErrorDetector { + abstract readonly name: string; + abstract canHandle(error: unknown, context: ConnectionContext): boolean; + abstract getHelp(error: unknown, context: ConnectionContext): ErrorHelp; + + /** + * Extract error message from unknown error + */ + protected getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); + } + + /** + * Extract error code from Node.js errors + */ + protected getErrorCode(error: unknown): string | undefined { + if (error instanceof Error) { + return (error as NodeJS.ErrnoException).code; + } + return undefined; + } + + /** + * Check if URL is a Supabase Direct Connection + */ + protected isDirectConnection(databaseUrl?: string): boolean { + if (!databaseUrl) return false; + return databaseUrl.includes('db.') && databaseUrl.includes('.supabase.co'); + } + + /** + * Check if URL is using Supabase Pooler + */ + protected isPoolerConnection(databaseUrl?: string): boolean { + if (!databaseUrl) return false; + return databaseUrl.includes('pooler.supabase.com'); + } + + /** + * Check if URL is using Transaction Pooler (port 6543) + */ + protected isTransactionPooler(databaseUrl?: string): boolean { + if (!databaseUrl) return false; + return this.isPoolerConnection(databaseUrl) && databaseUrl.includes(':6543'); + } + + /** + * Check if URL is using Session Pooler (port 5432 on pooler) + */ + protected isSessionPooler(databaseUrl?: string): boolean { + if (!databaseUrl) return false; + return this.isPoolerConnection(databaseUrl) && databaseUrl.includes(':5432'); + } + + /** + * Extract hostname from error message + */ + protected extractHostnameFromError(errorMessage: string): string | undefined { + const match = errorMessage.match(/ENOTFOUND\s+(\S+)/); + return match ? match[1] : undefined; + } + + /** + * Extract project reference from Supabase URL + */ + protected extractProjectRef(databaseUrl?: string): string | undefined { + if (!databaseUrl) return undefined; + + // Direct connection: db.PROJECT_REF.supabase.co + const directMatch = databaseUrl.match(/db\.([^.]+)\.supabase\.co/); + if (directMatch) return directMatch[1]; + + // Pooler connection: postgres.PROJECT_REF:password@ + const poolerMatch = databaseUrl.match(/postgres\.([^:@]+)[:|@]/); + if (poolerMatch) return poolerMatch[1]; + + return undefined; + } + + /** + * Create a base ErrorHelp with common defaults + */ + protected createErrorHelp( + partial: Omit & { isKnownError?: boolean } + ): ErrorHelp { + return { + isKnownError: true, + ...partial, + }; + } +} diff --git a/src/core/errors/detectors/connection-refused-detector.ts b/src/core/errors/detectors/connection-refused-detector.ts new file mode 100644 index 0000000..0ef404f --- /dev/null +++ b/src/core/errors/detectors/connection-refused-detector.ts @@ -0,0 +1,75 @@ +/** + * Detector for connection refused errors + * + * Occurs when the server actively refuses the connection, + * usually due to wrong port, firewall, or server not running. + */ + +import type { ErrorHelp, ConnectionContext } from '../types.js'; +import { BaseErrorDetector } from './base-detector.js'; + +export class ConnectionRefusedDetector extends BaseErrorDetector { + readonly name = 'connection-refused'; + + canHandle(error: unknown, _context: ConnectionContext): boolean { + const errorMessage = this.getErrorMessage(error); + const errorCode = this.getErrorCode(error); + + return errorCode === 'ECONNREFUSED' || errorMessage.includes('ECONNREFUSED'); + } + + getHelp(error: unknown, context: ConnectionContext): ErrorHelp { + const errorMessage = this.getErrorMessage(error); + const isPooler = this.isPoolerConnection(context.databaseUrl); + + const suggestions = [ + 'The database server actively refused the connection.', + '', + 'Possible causes and solutions:', + '', + '1. WRONG PORT NUMBER', + ]; + + if (isPooler) { + suggestions.push( + ' For Supabase Pooler:', + ' - Session Pooler: port 5432', + ' - Transaction Pooler: port 6543', + ' Check your connection string has the correct port.' + ); + } else { + suggestions.push( + ' - Verify the port number in your DATABASE_URL', + ' - Default PostgreSQL port is 5432' + ); + } + + suggestions.push( + '', + '2. IP NOT ALLOWED (Supabase)', + ' - Go to Supabase Dashboard > Settings > Database > Network', + ' - Add your IP address to the allowlist', + ' - Or enable "Allow all IPs" for development', + '', + '3. SERVER NOT RUNNING', + ' - If using local PostgreSQL, ensure it is running', + ' - Check server status: pg_isready -h localhost -p 5432', + '', + '4. FIREWALL BLOCKING', + ' - Check if firewall is blocking outbound connections', + ' - Port 5432 and 6543 need to be accessible', + '', + '5. ENV FILE FORMAT', + ' - Ensure DATABASE_URL is properly quoted:', + ' DATABASE_URL="postgres://..."' + ); + + return this.createErrorHelp({ + title: 'Connection Refused', + explanation: 'The database server refused the connection.', + suggestions, + docsUrl: 'https://supabase.com/docs/guides/database/connecting-to-postgres', + originalMessage: errorMessage, + }); + } +} diff --git a/src/core/errors/detectors/connection-timeout-detector.ts b/src/core/errors/detectors/connection-timeout-detector.ts new file mode 100644 index 0000000..bd05d47 --- /dev/null +++ b/src/core/errors/detectors/connection-timeout-detector.ts @@ -0,0 +1,79 @@ +/** + * Detector for connection timeout errors + * + * Occurs when the connection attempt takes too long, + * usually due to network issues or firewall silently dropping packets. + */ + +import type { ErrorHelp, ConnectionContext } from '../types.js'; +import { BaseErrorDetector } from './base-detector.js'; + +export class ConnectionTimeoutDetector extends BaseErrorDetector { + readonly name = 'connection-timeout'; + + canHandle(error: unknown, _context: ConnectionContext): boolean { + const errorMessage = this.getErrorMessage(error); + const errorCode = this.getErrorCode(error); + + return ( + errorCode === 'ETIMEDOUT' || + errorCode === 'ESOCKETTIMEDOUT' || + errorMessage.includes('ETIMEDOUT') || + errorMessage.includes('timeout') || + errorMessage.includes('timed out') + ); + } + + getHelp(error: unknown, context: ConnectionContext): ErrorHelp { + const errorMessage = this.getErrorMessage(error); + const isDirectConnection = this.isDirectConnection(context.databaseUrl); + + const suggestions = [ + 'The connection attempt timed out before completing.', + '', + 'Possible causes and solutions:', + '', + '1. NETWORK CONNECTIVITY', + ' - Check your internet connection', + ' - Try accessing other websites/services', + ' - Restart your router if needed', + ]; + + if (isDirectConnection) { + suggestions.push( + '', + '2. IPv6 ISSUES (Direct Connection)', + ' - You are using Direct Connection which requires IPv6', + ' - Your network may be timing out on IPv6 resolution', + ' - SOLUTION: Switch to Session Pooler instead', + ' - Get it from Dashboard > Connect > Session Pooler' + ); + } + + suggestions.push( + '', + `${isDirectConnection ? '3' : '2'}. FIREWALL/VPN`, + ' - Firewall may be silently dropping packets', + ' - Try disabling VPN temporarily', + ' - Check corporate firewall settings', + '', + `${isDirectConnection ? '4' : '3'}. SERVER OVERLOADED`, + ' - The database server might be under heavy load', + ' - Try again in a few minutes', + ' - Check Supabase status: https://status.supabase.com', + '', + `${isDirectConnection ? '5' : '4'}. WRONG HOST/PORT`, + ' - Verify your DATABASE_URL is correct', + ' - Copy a fresh connection string from Supabase Dashboard' + ); + + return this.createErrorHelp({ + title: 'Connection Timeout', + explanation: + 'The connection to the database timed out.\n' + + 'The server did not respond within the expected time.', + suggestions, + originalMessage: errorMessage, + }); + } +} diff --git a/src/core/errors/detectors/database-not-found-detector.ts b/src/core/errors/detectors/database-not-found-detector.ts new file mode 100644 index 0000000..42c8970 --- /dev/null +++ b/src/core/errors/detectors/database-not-found-detector.ts @@ -0,0 +1,61 @@ +/** + * Detector for database not found errors + * + * Occurs when the specified database doesn't exist on the server. + */ + +import type { ErrorHelp, ConnectionContext } from '../types.js'; +import { BaseErrorDetector } from './base-detector.js'; + +export class DatabaseNotFoundDetector extends BaseErrorDetector { + readonly name = 'database-not-found'; + + canHandle(error: unknown, _context: ConnectionContext): boolean { + const errorMessage = this.getErrorMessage(error).toLowerCase(); + + return ( + errorMessage.includes('database') && + (errorMessage.includes('does not exist') || errorMessage.includes('not found')) + ); + } + + getHelp(error: unknown, _context: ConnectionContext): ErrorHelp { + const errorMessage = this.getErrorMessage(error); + + // Try to extract database name from error + const dbMatch = errorMessage.match(/database "([^"]+)" does not exist/i); + const databaseName = dbMatch ? dbMatch[1] : 'specified database'; + + return this.createErrorHelp({ + title: 'Database Not Found', + explanation: + `The database "${databaseName}" does not exist on the server.\n` + + 'The connection was successful, but the specified database was not found.', + suggestions: [ + 'Possible causes and solutions:', + '', + '1. WRONG DATABASE NAME', + ' For Supabase, the default database is "postgres"', + ' Check your DATABASE_URL ends with /postgres', + '', + ' Example:', + ' postgres://user:pass@host:5432/postgres', + ' ^^^^^^^^', + '', + '2. TYPO IN DATABASE NAME', + ' Check for typos in the database name', + ' Copy a fresh connection string from Supabase Dashboard', + '', + '3. DATABASE WAS DELETED', + ' If using a custom database, it may have been deleted', + ' Check your Supabase dashboard for available databases', + '', + '4. ENV FILE FORMAT', + ' Make sure the full URL is correct:', + ' DATABASE_URL="postgres://user:pass@host:5432/postgres"', + ' Note: The database name comes after the last /', + ], + originalMessage: errorMessage, + }); + } +} diff --git a/src/core/errors/detectors/dns-direct-connection-detector.ts b/src/core/errors/detectors/dns-direct-connection-detector.ts new file mode 100644 index 0000000..c678c48 --- /dev/null +++ b/src/core/errors/detectors/dns-direct-connection-detector.ts @@ -0,0 +1,63 @@ +/** + * Detector for DNS resolution failures on Supabase Direct Connection + * + * This is the most common error users encounter when using Direct Connection, + * which only supports IPv6. Most networks don't have IPv6 support. + */ + +import type { ErrorHelp, ConnectionContext } from '../types.js'; +import { BaseErrorDetector } from './base-detector.js'; + +export class DnsDirectConnectionDetector extends BaseErrorDetector { + readonly name = 'dns-direct-connection'; + + canHandle(error: unknown, context: ConnectionContext): boolean { + const errorMessage = this.getErrorMessage(error); + const errorCode = this.getErrorCode(error); + + const isDnsError = + errorCode === 'ENOTFOUND' || errorMessage.includes('getaddrinfo ENOTFOUND'); + + return isDnsError && this.isDirectConnection(context.databaseUrl); + } + + getHelp(error: unknown, context: ConnectionContext): ErrorHelp { + const errorMessage = this.getErrorMessage(error); + const hostname = + this.extractHostnameFromError(errorMessage) || context.hostname || 'unknown host'; + const projectRef = this.extractProjectRef(context.databaseUrl) || 'YOUR_PROJECT_REF'; + + return this.createErrorHelp({ + title: 'DNS Resolution Failed - Direct Connection (IPv6 Only)', + explanation: + `Cannot resolve hostname: ${hostname}\n\n` + + 'You are using a Supabase Direct Connection, which only supports IPv6.\n' + + 'Your network likely does not support IPv6, causing the DNS lookup to fail.\n\n' + + 'This is the most common connection error with Supabase.', + suggestions: [ + 'SOLUTION: Switch to Session Pooler', + '', + '1. Go to your Supabase Dashboard (https://supabase.com/dashboard)', + '2. Select your project', + '3. Click the "Connect" button at the top', + '4. Select "Session Pooler" (NOT "Direct Connection")', + '5. Copy the new connection string', + '', + 'Your connection string format will change:', + '', + 'FROM (Direct - IPv6 only, does NOT work on most networks):', + ` postgres://postgres:PASSWORD@db.${projectRef}.supabase.co:5432/postgres`, + '', + 'TO (Session Pooler - works everywhere):', + ` postgres://postgres.${projectRef}:PASSWORD@aws-0-REGION.pooler.supabase.com:5432/postgres`, + '', + 'Note: The username changes from "postgres" to "postgres.PROJECT_REF"', + '', + 'After updating your .env file, make sure the URL is wrapped in quotes:', + ' DATABASE_URL="postgres://postgres.xxx:password@aws-0-region.pooler.supabase.com:5432/postgres"', + ], + docsUrl: 'https://supabase.com/docs/guides/database/connecting-to-postgres', + originalMessage: errorMessage, + }); + } +} diff --git a/src/core/errors/detectors/dns-generic-detector.ts b/src/core/errors/detectors/dns-generic-detector.ts new file mode 100644 index 0000000..6433d4f --- /dev/null +++ b/src/core/errors/detectors/dns-generic-detector.ts @@ -0,0 +1,59 @@ +/** + * Detector for generic DNS resolution failures + * + * Handles DNS errors that are not specifically related to Supabase Direct Connection. + */ + +import type { ErrorHelp, ConnectionContext } from '../types.js'; +import { BaseErrorDetector } from './base-detector.js'; + +export class DnsGenericDetector extends BaseErrorDetector { + readonly name = 'dns-generic'; + + canHandle(error: unknown, _context: ConnectionContext): boolean { + const errorMessage = this.getErrorMessage(error); + const errorCode = this.getErrorCode(error); + + return errorCode === 'ENOTFOUND' || errorMessage.includes('getaddrinfo ENOTFOUND'); + } + + getHelp(error: unknown, context: ConnectionContext): ErrorHelp { + const errorMessage = this.getErrorMessage(error); + const hostname = + this.extractHostnameFromError(errorMessage) || context.hostname || 'unknown host'; + + return this.createErrorHelp({ + title: 'DNS Resolution Failed', + explanation: + `Cannot resolve hostname: ${hostname}\n\n` + + 'The DNS lookup failed, meaning the hostname could not be found.', + suggestions: [ + 'Possible causes and solutions:', + '', + '1. TYPO IN HOSTNAME', + ' - Double-check the hostname in your DATABASE_URL', + ' - Copy a fresh connection string from Supabase Dashboard > Connect', + '', + '2. NETWORK ISSUES', + ' - Check your internet connection', + ' - Try pinging a known host: ping google.com', + ' - Check if your DNS server is working', + '', + '3. FIREWALL/VPN BLOCKING', + ' - Try disabling VPN temporarily', + ' - Check if corporate firewall is blocking the connection', + '', + '4. ENV FILE FORMAT', + ' - Make sure your .env file has the URL in quotes:', + ' DATABASE_URL="postgres://..."', + ' - Check for invisible characters or line break issues', + '', + 'For Supabase users:', + ' - Use Session Pooler instead of Direct Connection', + ' - Get connection string from Dashboard > Connect > Session Pooler', + ], + docsUrl: 'https://supabase.com/docs/guides/database/connecting-to-postgres', + originalMessage: errorMessage, + }); + } +} diff --git a/src/core/errors/detectors/index.ts b/src/core/errors/detectors/index.ts new file mode 100644 index 0000000..048e9e0 --- /dev/null +++ b/src/core/errors/detectors/index.ts @@ -0,0 +1,15 @@ +/** + * Error detectors - each handles ONE specific type of error (SRP) + */ + +export { BaseErrorDetector } from './base-detector.js'; +export { DnsDirectConnectionDetector } from './dns-direct-connection-detector.js'; +export { DnsGenericDetector } from './dns-generic-detector.js'; +export { ConnectionRefusedDetector } from './connection-refused-detector.js'; +export { ConnectionTimeoutDetector } from './connection-timeout-detector.js'; +export { AuthenticationDetector } from './authentication-detector.js'; +export { SslDetector } from './ssl-detector.js'; +export { PreparedStatementDetector } from './prepared-statement-detector.js'; +export { DatabaseNotFoundDetector } from './database-not-found-detector.js'; +export { TooManyConnectionsDetector } from './too-many-connections-detector.js'; +export { InvalidUrlDetector } from './invalid-url-detector.js'; diff --git a/src/core/errors/detectors/invalid-url-detector.ts b/src/core/errors/detectors/invalid-url-detector.ts new file mode 100644 index 0000000..c912388 --- /dev/null +++ b/src/core/errors/detectors/invalid-url-detector.ts @@ -0,0 +1,76 @@ +/** + * Detector for invalid database URL format errors + * + * Occurs when the DATABASE_URL is malformed or has invalid syntax. + */ + +import type { ErrorHelp, ConnectionContext } from '../types.js'; +import { BaseErrorDetector } from './base-detector.js'; + +export class InvalidUrlDetector extends BaseErrorDetector { + readonly name = 'invalid-url'; + + canHandle(error: unknown, _context: ConnectionContext): boolean { + const errorMessage = this.getErrorMessage(error).toLowerCase(); + + return ( + errorMessage.includes('invalid url') || + errorMessage.includes('invalid database url') || + errorMessage.includes('malformed url') || + errorMessage.includes('url parse') || + errorMessage.includes('invalid protocol') || + (error instanceof TypeError && errorMessage.includes('url')) + ); + } + + getHelp(error: unknown, _context: ConnectionContext): ErrorHelp { + const errorMessage = this.getErrorMessage(error); + + return this.createErrorHelp({ + title: 'Invalid Database URL Format', + explanation: + 'The DATABASE_URL format is invalid or malformed.\n' + + 'Cannot parse the connection string.', + suggestions: [ + 'CORRECT FORMAT:', + ' postgres://USERNAME:PASSWORD@HOST:PORT/DATABASE', + '', + 'SUPABASE EXAMPLES:', + '', + 'Session Pooler (recommended):', + ' postgres://postgres.PROJECT_REF:PASSWORD@aws-0-REGION.pooler.supabase.com:5432/postgres', + '', + 'Direct Connection:', + ' postgres://postgres:PASSWORD@db.PROJECT_REF.supabase.co:5432/postgres', + '', + 'COMMON MISTAKES:', + '', + '1. MISSING QUOTES IN .env FILE', + ' WRONG: DATABASE_URL=postgres://...', + ' RIGHT: DATABASE_URL="postgres://..."', + '', + '2. SPACES IN THE URL', + ' Make sure there are no spaces in the URL', + '', + '3. WRONG PROTOCOL', + ' Use "postgres://" or "postgresql://"', + ' NOT "http://" or "https://"', + '', + '4. SPECIAL CHARACTERS NOT ENCODED', + ' If password contains special chars, URL encode them:', + ' @ -> %40, # -> %23, : -> %3A', + '', + '5. MISSING PARTS', + ' Make sure URL has: protocol://user:pass@host:port/database', + '', + 'GET A FRESH CONNECTION STRING:', + ' 1. Go to Supabase Dashboard', + ' 2. Click "Connect" at the top', + ' 3. Select "Session Pooler"', + ' 4. Copy the connection string', + ], + docsUrl: 'https://supabase.com/docs/guides/database/connecting-to-postgres', + originalMessage: errorMessage, + }); + } +} diff --git a/src/core/errors/detectors/prepared-statement-detector.ts b/src/core/errors/detectors/prepared-statement-detector.ts new file mode 100644 index 0000000..bd13dc2 --- /dev/null +++ b/src/core/errors/detectors/prepared-statement-detector.ts @@ -0,0 +1,65 @@ +/** + * Detector for prepared statement errors on Transaction Pooler + * + * Transaction Pooler (port 6543) does not support prepared statements. + * This error is rare with this package since node-postgres doesn't use + * prepared statements by default. + */ + +import type { ErrorHelp, ConnectionContext } from '../types.js'; +import { BaseErrorDetector } from './base-detector.js'; + +export class PreparedStatementDetector extends BaseErrorDetector { + readonly name = 'prepared-statement'; + + canHandle(error: unknown, context: ConnectionContext): boolean { + const errorMessage = this.getErrorMessage(error).toLowerCase(); + + const isPreparedStatementError = + errorMessage.includes('prepared statement') || + errorMessage.includes('prepared_statement'); + + // Only flag as known error if using Transaction Pooler + return isPreparedStatementError && this.isTransactionPooler(context.databaseUrl); + } + + getHelp(error: unknown, context: ConnectionContext): ErrorHelp { + const errorMessage = this.getErrorMessage(error); + const projectRef = this.extractProjectRef(context.databaseUrl) || 'YOUR_PROJECT_REF'; + + return this.createErrorHelp({ + title: 'Prepared Statement Error (Transaction Pooler)', + explanation: + 'You are using Transaction Pooler (port 6543), which does NOT support prepared statements.\n\n' + + 'Transaction Pooler shares database connections between clients, so prepared statements\n' + + 'from one client may conflict with another.', + suggestions: [ + 'SOLUTION: Switch to Session Pooler (port 5432)', + '', + 'Change your connection string port from 6543 to 5432:', + '', + 'FROM (Transaction Pooler - port 6543):', + ` postgres://postgres.${projectRef}:PASSWORD@aws-0-REGION.pooler.supabase.com:6543/postgres`, + '', + 'TO (Session Pooler - port 5432):', + ` postgres://postgres.${projectRef}:PASSWORD@aws-0-REGION.pooler.supabase.com:5432/postgres`, + '', + 'Session Pooler supports:', + ' - Prepared statements', + ' - Long-running transactions', + ' - Full PostgreSQL compatibility', + '', + 'Transaction Pooler (port 6543) is only recommended for:', + ' - Serverless functions with very short queries', + ' - High-concurrency scenarios with simple queries', + '', + 'NOTE: This package uses node-postgres which doesn\'t use prepared', + 'statements by default, so this error is unusual. You may have', + 'custom code that explicitly creates prepared statements.', + ], + docsUrl: + 'https://supabase.com/docs/guides/troubleshooting/disabling-prepared-statements-qL8lEL', + originalMessage: errorMessage, + }); + } +} diff --git a/src/core/errors/detectors/ssl-detector.ts b/src/core/errors/detectors/ssl-detector.ts new file mode 100644 index 0000000..297136c --- /dev/null +++ b/src/core/errors/detectors/ssl-detector.ts @@ -0,0 +1,90 @@ +/** + * Detector for SSL/TLS connection errors + * + * Occurs when there are SSL handshake failures or certificate issues. + */ + +import type { ErrorHelp, ConnectionContext } from '../types.js'; +import { BaseErrorDetector } from './base-detector.js'; + +export class SslDetector extends BaseErrorDetector { + readonly name = 'ssl'; + + canHandle(error: unknown, _context: ConnectionContext): boolean { + const errorMessage = this.getErrorMessage(error).toLowerCase(); + + return ( + errorMessage.includes('ssl') || + errorMessage.includes('tls') || + errorMessage.includes('certificate') || + errorMessage.includes('cert') || + errorMessage.includes('handshake') || + errorMessage.includes('self signed') || + errorMessage.includes('unable to verify') + ); + } + + getHelp(error: unknown, _context: ConnectionContext): ErrorHelp { + const errorMessage = this.getErrorMessage(error); + + const isCertError = + errorMessage.toLowerCase().includes('certificate') || + errorMessage.toLowerCase().includes('self signed') || + errorMessage.toLowerCase().includes('unable to verify'); + + const suggestions = [ + 'There was a problem with the SSL/TLS connection.', + '', + 'Possible causes and solutions:', + '', + '1. SSL REQUIRED BUT NOT ENABLED', + ' Supabase requires SSL connections by default.', + ' Make sure you are NOT disabling SSL in your config:', + '', + ' // DON\'T do this:', + ' ssl: false // This will fail!', + '', + ' // DO this (default):', + ' ssl: true', + ' // or', + ' ssl: { rejectUnauthorized: false }', + ]; + + if (isCertError) { + suggestions.push( + '', + '2. CERTIFICATE VERIFICATION FAILED', + ' If you see "self signed" or "unable to verify" errors:', + '', + ' Use this SSL configuration:', + ' ssl: { rejectUnauthorized: false }', + '', + ' This is the default in this package and should work.', + ' If you overrode it, try removing your custom SSL config.' + ); + } + + suggestions.push( + '', + `${isCertError ? '3' : '2'}. NETWORK INTERCEPTING SSL`, + ' - Corporate proxies may intercept SSL traffic', + ' - VPNs might interfere with SSL connections', + ' - Try disabling VPN/proxy temporarily', + '', + `${isCertError ? '4' : '3'}. OUTDATED NODE.JS`, + ' - Very old Node.js versions may have SSL issues', + ' - This package requires Node.js 18+', + ' - Check your version: node --version' + ); + + return this.createErrorHelp({ + title: 'SSL Connection Error', + explanation: + 'There was a problem establishing a secure SSL connection.\n' + + 'Supabase requires SSL for all database connections.', + suggestions, + docsUrl: 'https://supabase.com/docs/guides/platform/ssl-enforcement', + originalMessage: errorMessage, + }); + } +} diff --git a/src/core/errors/detectors/too-many-connections-detector.ts b/src/core/errors/detectors/too-many-connections-detector.ts new file mode 100644 index 0000000..ee74394 --- /dev/null +++ b/src/core/errors/detectors/too-many-connections-detector.ts @@ -0,0 +1,84 @@ +/** + * Detector for too many connections errors + * + * Occurs when the database has reached its maximum connection limit. + */ + +import type { ErrorHelp, ConnectionContext } from '../types.js'; +import { BaseErrorDetector } from './base-detector.js'; + +export class TooManyConnectionsDetector extends BaseErrorDetector { + readonly name = 'too-many-connections'; + + canHandle(error: unknown, _context: ConnectionContext): boolean { + const errorMessage = this.getErrorMessage(error).toLowerCase(); + + return ( + errorMessage.includes('too many connections') || + errorMessage.includes('connection limit') || + errorMessage.includes('max_connections') || + errorMessage.includes('remaining connection slots') + ); + } + + getHelp(error: unknown, context: ConnectionContext): ErrorHelp { + const errorMessage = this.getErrorMessage(error); + const isDirectConnection = this.isDirectConnection(context.databaseUrl); + const isTransactionPooler = this.isTransactionPooler(context.databaseUrl); + + const suggestions = [ + 'The database has reached its maximum number of connections.', + '', + 'Possible causes and solutions:', + '', + '1. TOO MANY OPEN CONNECTIONS', + ' - Close unused database connections', + ' - Check for connection leaks in your code', + ' - Make sure connections are properly closed after use', + ]; + + if (isDirectConnection) { + suggestions.push( + '', + '2. USE CONNECTION POOLER', + ' You are using Direct Connection, which doesn\'t pool connections.', + ' Switch to Session Pooler for better connection management:', + ' - Go to Supabase Dashboard > Connect > Session Pooler', + ' - Copy the new connection string' + ); + } + + if (!isTransactionPooler) { + suggestions.push( + '', + `${isDirectConnection ? '3' : '2'}. USE TRANSACTION POOLER FOR HIGH CONCURRENCY`, + ' If you have many short-lived connections (serverless functions),', + ' consider using Transaction Pooler (port 6543) which allows more', + ' concurrent connections.' + ); + } + + suggestions.push( + '', + `${isDirectConnection ? '4' : isTransactionPooler ? '2' : '3'}. UPGRADE SUPABASE PLAN`, + ' Higher Supabase plans have higher connection limits:', + ' - Free: 60 connections', + ' - Pro: 200 connections', + ' - Team: 300 connections', + '', + `${isDirectConnection ? '5' : isTransactionPooler ? '3' : '4'}. WAIT AND RETRY`, + ' The issue may be temporary.', + ' Wait a few seconds and try again.' + ); + + return this.createErrorHelp({ + title: 'Too Many Connections', + explanation: + 'The database has reached its maximum connection limit.\n' + + 'No more connections can be established until some are closed.', + suggestions, + docsUrl: 'https://supabase.com/docs/guides/database/connection-management', + originalMessage: errorMessage, + }); + } +} diff --git a/src/core/errors/error-handler.ts b/src/core/errors/error-handler.ts new file mode 100644 index 0000000..5dfec64 --- /dev/null +++ b/src/core/errors/error-handler.ts @@ -0,0 +1,175 @@ +/** + * Connection Error Handler + * + * Main entry point for error handling. Coordinates between: + * - Error detectors (identify error type) + * - Error formatters (format for display) + * + * Follows Dependency Inversion Principle - depends on abstractions + * (interfaces), not concrete implementations. + */ + +import type { + ErrorHelp, + ErrorFormatter, + ErrorDetectorRegistry, + ConnectionContext, +} from './types.js'; +import { createDefaultRegistry } from './registry.js'; +import { ConsoleErrorFormatter } from './formatters.js'; + +/** + * Options for the ConnectionErrorHandler + */ +export interface ErrorHandlerOptions { + /** Custom registry with detectors (optional, uses default if not provided) */ + registry?: ErrorDetectorRegistry; + /** Custom formatter (optional, uses ConsoleErrorFormatter if not provided) */ + formatter?: ErrorFormatter; +} + +/** + * Main error handler class + * + * Usage: + * ```ts + * const handler = new ConnectionErrorHandler(); + * const help = handler.getHelp(error, { databaseUrl }); + * console.error(handler.format(help)); + * ``` + */ +export class ConnectionErrorHandler { + private readonly registry: ErrorDetectorRegistry; + private readonly formatter: ErrorFormatter; + + constructor(options: ErrorHandlerOptions = {}) { + this.registry = options.registry ?? createDefaultRegistry(); + this.formatter = options.formatter ?? new ConsoleErrorFormatter(); + } + + /** + * Analyze an error and get help information + * + * @param error - The error to analyze + * @param context - Connection context (URL, etc.) + * @returns ErrorHelp object with analysis and suggestions + */ + getHelp(error: unknown, context: ConnectionContext = {}): ErrorHelp { + const detector = this.registry.findDetector(error, context); + + if (detector) { + return detector.getHelp(error, context); + } + + // No detector found - return generic error + return this.createGenericError(error); + } + + /** + * Format error help for display + * + * @param help - The ErrorHelp to format + * @returns Formatted string + */ + format(help: ErrorHelp): string { + return this.formatter.format(help); + } + + /** + * Convenience method: analyze and format in one call + * + * @param error - The error to handle + * @param context - Connection context + * @returns Formatted error string + */ + handleError(error: unknown, context: ConnectionContext = {}): string { + const help = this.getHelp(error, context); + return this.format(help); + } + + /** + * Check if an error is a known connection error + * + * @param error - The error to check + * @param context - Connection context + * @returns true if error is recognized + */ + isKnownError(error: unknown, context: ConnectionContext = {}): boolean { + return this.registry.findDetector(error, context) !== undefined; + } + + /** + * Get the registry (for advanced usage) + */ + getRegistry(): ErrorDetectorRegistry { + return this.registry; + } + + /** + * Create generic error response when no detector matches + */ + private createGenericError(error: unknown): ErrorHelp { + const message = error instanceof Error ? error.message : String(error); + + return { + isKnownError: false, + title: 'Connection Error', + explanation: message, + suggestions: [ + 'This error was not recognized. Here are some general suggestions:', + '', + '1. Check your DATABASE_URL is correct', + '2. Verify the database server is running', + '3. Check your network connection', + '4. Try copying a fresh connection string from Supabase Dashboard > Connect', + '', + 'If using Supabase, make sure to use Session Pooler (not Direct Connection)', + 'for best compatibility.', + ], + docsUrl: 'https://supabase.com/docs/guides/database/connecting-to-postgres', + originalMessage: message, + }; + } +} + +// ============================================================================ +// Convenience functions for simple usage +// ============================================================================ + +/** Singleton instance for convenience functions */ +let defaultHandler: ConnectionErrorHandler | null = null; + +/** + * Get or create the default error handler + */ +function getDefaultHandler(): ConnectionErrorHandler { + if (!defaultHandler) { + defaultHandler = new ConnectionErrorHandler(); + } + return defaultHandler; +} + +/** + * Analyze a connection error and get help + * + * @param error - The error to analyze + * @param databaseUrl - The database URL that was used (optional) + * @returns ErrorHelp object + */ +export function getConnectionErrorHelp( + error: unknown, + databaseUrl?: string +): ErrorHelp { + return getDefaultHandler().getHelp(error, { databaseUrl }); +} + +/** + * Format error help for console output + * + * @param help - The ErrorHelp to format + * @returns Formatted string + */ +export function formatConnectionErrorHelp(help: ErrorHelp): string { + return getDefaultHandler().format(help); +} + diff --git a/src/core/errors/formatters.ts b/src/core/errors/formatters.ts new file mode 100644 index 0000000..1228d71 --- /dev/null +++ b/src/core/errors/formatters.ts @@ -0,0 +1,169 @@ +/** + * Error Formatters + * + * Responsible for converting ErrorHelp objects to display formats. + * Follows Dependency Inversion Principle - high-level modules depend + * on abstractions (ErrorFormatter interface), not concrete implementations. + */ + +import type { ErrorHelp, ErrorFormatter } from './types.js'; + +/** + * Console formatter with box drawing characters + * Creates a visually distinct error message for terminal output + */ +export class ConsoleErrorFormatter implements ErrorFormatter { + private readonly borderChar: string; + private readonly width: number; + + constructor(options: { borderChar?: string; width?: number } = {}) { + this.borderChar = options.borderChar ?? '═'; + this.width = options.width ?? 70; + } + + format(help: ErrorHelp): string { + const lines: string[] = []; + const border = this.borderChar.repeat(this.width); + + // Header + lines.push(''); + lines.push(border); + lines.push(this.centerText(help.title, this.width)); + lines.push(border); + lines.push(''); + + // Explanation + lines.push(help.explanation); + lines.push(''); + + // Suggestions + if (help.suggestions.length > 0) { + for (const suggestion of help.suggestions) { + lines.push(suggestion); + } + lines.push(''); + } + + // Documentation link + if (help.docsUrl) { + lines.push(`📚 Documentation: ${help.docsUrl}`); + lines.push(''); + } + + // Footer + lines.push(border); + + return lines.join('\n'); + } + + private centerText(text: string, width: number): string { + const padding = Math.max(0, Math.floor((width - text.length) / 2)); + return ' '.repeat(padding) + text; + } +} + +/** + * Simple text formatter without decorations + * Useful for logging or non-terminal output + */ +export class SimpleErrorFormatter implements ErrorFormatter { + format(help: ErrorHelp): string { + const lines: string[] = []; + + lines.push(`ERROR: ${help.title}`); + lines.push(''); + lines.push(help.explanation); + lines.push(''); + + if (help.suggestions.length > 0) { + lines.push('Suggestions:'); + for (const suggestion of help.suggestions) { + if (suggestion.trim()) { + lines.push(` ${suggestion}`); + } + } + lines.push(''); + } + + if (help.docsUrl) { + lines.push(`Documentation: ${help.docsUrl}`); + } + + return lines.join('\n'); + } +} + +/** + * JSON formatter for structured output + * Useful for programmatic error handling or logging systems + */ +export class JsonErrorFormatter implements ErrorFormatter { + private readonly pretty: boolean; + + constructor(options: { pretty?: boolean } = {}) { + this.pretty = options.pretty ?? false; + } + + format(help: ErrorHelp): string { + const output = { + error: { + title: help.title, + explanation: help.explanation, + suggestions: help.suggestions.filter((s) => s.trim()), + documentation: help.docsUrl, + originalMessage: help.originalMessage, + }, + }; + + return this.pretty ? JSON.stringify(output, null, 2) : JSON.stringify(output); + } +} + +/** + * Markdown formatter for documentation or issue reporting + */ +export class MarkdownErrorFormatter implements ErrorFormatter { + format(help: ErrorHelp): string { + const lines: string[] = []; + + lines.push(`## ❌ ${help.title}`); + lines.push(''); + lines.push(help.explanation); + lines.push(''); + + if (help.suggestions.length > 0) { + lines.push('### Suggested Solutions'); + lines.push(''); + lines.push('```'); + for (const suggestion of help.suggestions) { + lines.push(suggestion); + } + lines.push('```'); + lines.push(''); + } + + if (help.docsUrl) { + lines.push(`### Documentation`); + lines.push(''); + lines.push(`📚 [View Documentation](${help.docsUrl})`); + lines.push(''); + } + + if (help.originalMessage) { + lines.push('### Original Error'); + lines.push(''); + lines.push('```'); + lines.push(help.originalMessage); + lines.push('```'); + } + + return lines.join('\n'); + } +} + +/** + * Factory function to create the default formatter + */ +export function createDefaultFormatter(): ErrorFormatter { + return new ConsoleErrorFormatter(); +} diff --git a/src/core/errors/index.ts b/src/core/errors/index.ts new file mode 100644 index 0000000..f34722c --- /dev/null +++ b/src/core/errors/index.ts @@ -0,0 +1,72 @@ +/** + * Error handling module + * + * Provides comprehensive error detection, analysis, and formatting + * for database connection errors. Built following SOLID principles. + * + * @example Basic usage + * ```ts + * import { getConnectionErrorHelp, formatConnectionErrorHelp } from './errors'; + * + * try { + * await connect(); + * } catch (error) { + * const help = getConnectionErrorHelp(error, databaseUrl); + * console.error(formatConnectionErrorHelp(help)); + * } + * ``` + * + * @example Advanced usage with custom handler + * ```ts + * import { ConnectionErrorHandler, JsonErrorFormatter } from './errors'; + * + * const handler = new ConnectionErrorHandler({ + * formatter: new JsonErrorFormatter({ pretty: true }), + * }); + * + * const help = handler.getHelp(error, { databaseUrl }); + * console.log(handler.format(help)); + * ``` + */ + +// Types +export type { + ConnectionContext, + ErrorHelp, + ErrorDetector, + ErrorFormatter, + ErrorDetectorRegistry, +} from './types.js'; + +// Main error handler +export { + ConnectionErrorHandler, + getConnectionErrorHelp, + formatConnectionErrorHelp, +} from './error-handler.js'; +export type { ErrorHandlerOptions } from './error-handler.js'; + +// Registry +export { DefaultErrorDetectorRegistry, createDefaultRegistry } from './registry.js'; + +// Formatters +export { + ConsoleErrorFormatter, + SimpleErrorFormatter, + JsonErrorFormatter, + MarkdownErrorFormatter, + createDefaultFormatter, +} from './formatters.js'; + +// Detectors (for extension) +export { BaseErrorDetector } from './detectors/base-detector.js'; +export { DnsDirectConnectionDetector } from './detectors/dns-direct-connection-detector.js'; +export { DnsGenericDetector } from './detectors/dns-generic-detector.js'; +export { ConnectionRefusedDetector } from './detectors/connection-refused-detector.js'; +export { ConnectionTimeoutDetector } from './detectors/connection-timeout-detector.js'; +export { AuthenticationDetector } from './detectors/authentication-detector.js'; +export { SslDetector } from './detectors/ssl-detector.js'; +export { PreparedStatementDetector } from './detectors/prepared-statement-detector.js'; +export { DatabaseNotFoundDetector } from './detectors/database-not-found-detector.js'; +export { TooManyConnectionsDetector } from './detectors/too-many-connections-detector.js'; +export { InvalidUrlDetector } from './detectors/invalid-url-detector.js'; diff --git a/src/core/errors/registry.ts b/src/core/errors/registry.ts new file mode 100644 index 0000000..a0cd9ea --- /dev/null +++ b/src/core/errors/registry.ts @@ -0,0 +1,120 @@ +/** + * Error Detector Registry + * + * Manages a collection of error detectors and finds the appropriate one + * for a given error. Follows Open/Closed Principle - open for extension + * (adding new detectors) but closed for modification. + */ + +import type { ErrorDetector, ErrorDetectorRegistry, ConnectionContext } from './types.js'; +import { DnsDirectConnectionDetector } from './detectors/dns-direct-connection-detector.js'; +import { DnsGenericDetector } from './detectors/dns-generic-detector.js'; +import { ConnectionRefusedDetector } from './detectors/connection-refused-detector.js'; +import { ConnectionTimeoutDetector } from './detectors/connection-timeout-detector.js'; +import { AuthenticationDetector } from './detectors/authentication-detector.js'; +import { SslDetector } from './detectors/ssl-detector.js'; +import { PreparedStatementDetector } from './detectors/prepared-statement-detector.js'; +import { DatabaseNotFoundDetector } from './detectors/database-not-found-detector.js'; +import { TooManyConnectionsDetector } from './detectors/too-many-connections-detector.js'; +import { InvalidUrlDetector } from './detectors/invalid-url-detector.js'; + +/** + * Default implementation of ErrorDetectorRegistry + * + * Detectors are checked in order of registration, so more specific + * detectors should be registered before more generic ones. + */ +export class DefaultErrorDetectorRegistry implements ErrorDetectorRegistry { + private detectors: ErrorDetector[] = []; + + /** + * Register a new error detector + * Order matters - first registered detector that matches wins + * + * @param detector - The detector to register + */ + register(detector: ErrorDetector): void { + this.detectors.push(detector); + } + + /** + * Register multiple detectors at once + * + * @param detectors - Array of detectors to register + */ + registerAll(detectors: ErrorDetector[]): void { + for (const detector of detectors) { + this.register(detector); + } + } + + /** + * Find a detector that can handle the given error + * + * @param error - The error to find a detector for + * @param context - Additional context about the connection + * @returns The first matching detector, or undefined if none found + */ + findDetector(error: unknown, context: ConnectionContext): ErrorDetector | undefined { + for (const detector of this.detectors) { + if (detector.canHandle(error, context)) { + return detector; + } + } + return undefined; + } + + /** + * Get all registered detectors (readonly) + */ + getDetectors(): readonly ErrorDetector[] { + return [...this.detectors]; + } + + /** + * Get count of registered detectors + */ + get count(): number { + return this.detectors.length; + } + + /** + * Check if a detector with the given name is registered + */ + hasDetector(name: string): boolean { + return this.detectors.some((d) => d.name === name); + } +} + +/** + * Create a registry with all default detectors pre-registered + * Detectors are registered in order of specificity (most specific first) + */ +export function createDefaultRegistry(): DefaultErrorDetectorRegistry { + const registry = new DefaultErrorDetectorRegistry(); + + // Register detectors in order of specificity + // More specific detectors first, more generic ones last + + // DNS errors - specific before generic + registry.register(new DnsDirectConnectionDetector()); + registry.register(new DnsGenericDetector()); + + // Connection errors + registry.register(new ConnectionRefusedDetector()); + registry.register(new ConnectionTimeoutDetector()); + + // Authentication and authorization + registry.register(new AuthenticationDetector()); + registry.register(new DatabaseNotFoundDetector()); + registry.register(new TooManyConnectionsDetector()); + + // Protocol-specific errors + registry.register(new SslDetector()); + registry.register(new PreparedStatementDetector()); + + // URL parsing errors + registry.register(new InvalidUrlDetector()); + + return registry; +} diff --git a/src/core/errors/types.ts b/src/core/errors/types.ts new file mode 100644 index 0000000..3b48834 --- /dev/null +++ b/src/core/errors/types.ts @@ -0,0 +1,98 @@ +/** + * Error handling types and interfaces + * Following Interface Segregation Principle (ISP) + */ + +/** + * Context information about the connection attempt + */ +export interface ConnectionContext { + /** The database URL that was used (may be undefined) */ + databaseUrl?: string; + /** The hostname extracted from the URL */ + hostname?: string; + /** The port number */ + port?: number; +} + +/** + * Structured error information for display + */ +export interface ErrorHelp { + /** Whether this error was recognized and handled */ + isKnownError: boolean; + /** Short, descriptive title for the error */ + title: string; + /** Detailed explanation of what went wrong */ + explanation: string; + /** List of suggested solutions or next steps */ + suggestions: string[]; + /** Optional link to relevant documentation */ + docsUrl?: string; + /** Original error message for reference */ + originalMessage?: string; +} + +/** + * Interface for error detectors + * Each detector is responsible for identifying ONE type of error (SRP) + */ +export interface ErrorDetector { + /** Unique identifier for this detector */ + readonly name: string; + + /** + * Check if this detector can handle the given error + * @param error - The error to check + * @param context - Additional context about the connection + * @returns true if this detector can handle the error + */ + canHandle(error: unknown, context: ConnectionContext): boolean; + + /** + * Generate help information for the error + * Should only be called if canHandle() returned true + * @param error - The error to analyze + * @param context - Additional context about the connection + * @returns Structured help information + */ + getHelp(error: unknown, context: ConnectionContext): ErrorHelp; +} + +/** + * Interface for error formatters + * Responsible for converting ErrorHelp to display format + */ +export interface ErrorFormatter { + /** + * Format error help for output + * @param help - The error help to format + * @returns Formatted string for display + */ + format(help: ErrorHelp): string; +} + +/** + * Interface for the error handler registry + * Manages collection of error detectors + */ +export interface ErrorDetectorRegistry { + /** + * Register a new error detector + * @param detector - The detector to register + */ + register(detector: ErrorDetector): void; + + /** + * Find a detector that can handle the given error + * @param error - The error to find a detector for + * @param context - Additional context about the connection + * @returns The matching detector, or undefined if none found + */ + findDetector(error: unknown, context: ConnectionContext): ErrorDetector | undefined; + + /** + * Get all registered detectors + */ + getDetectors(): readonly ErrorDetector[]; +} diff --git a/src/core/executor.ts b/src/core/executor.ts new file mode 100644 index 0000000..45a1147 --- /dev/null +++ b/src/core/executor.ts @@ -0,0 +1,320 @@ +import { Client } from 'pg'; +import type { + ConnectionConfig, + FileExecutionResult, + Logger, + SqlRunnerError, +} from '../types.js'; +import { readSqlFile, createSavepointName } from './file-scanner.js'; +import * as path from 'node:path'; + +/** + * Safely quotes a PostgreSQL identifier to prevent SQL injection + * Uses double quotes and escapes any internal double quotes + * + * @param identifier - The identifier to quote + * @returns Safely quoted identifier + */ +function quoteSqlIdentifier(identifier: string): string { + // Escape any double quotes by doubling them, then wrap in double quotes + return `"${identifier.replace(/"/g, '""')}"`; +} + +/** + * SQL Executor - handles database operations + */ +export class SqlExecutor { + private client: Client | null = null; + private logger: Logger; + private onNotice?: (message: string) => void; + private noticeHandler: ((msg: { message?: string }) => void) | null = null; + + constructor( + private config: ConnectionConfig, + logger: Logger, + onNotice?: (message: string) => void + ) { + this.logger = logger; + this.onNotice = onNotice; + } + + /** + * Connects to the database + */ + async connect(): Promise { + this.client = new Client({ + host: this.config.host, + port: this.config.port, + database: this.config.database, + user: this.config.user, + password: this.config.password, + ssl: this.config.ssl, + }); + + // Set up notice handler for RAISE NOTICE messages + this.noticeHandler = (msg) => { + if (this.onNotice && msg.message) { + this.onNotice(msg.message); + } + }; + this.client.on('notice', this.noticeHandler); + + // Handle unexpected errors from the server (e.g., connection termination) + // This prevents unhandled 'error' events from crashing the process + this.client.on('error', (err) => { + // Log but don't crash - these are often expected during error recovery + this.logger.debug(`Database connection error: ${err.message}`); + }); + + await this.client.connect(); + this.logger.success(`Connected to database at ${this.config.host}:${this.config.port}`); + } + + /** + * Disconnects from the database + */ + async disconnect(): Promise { + if (this.client) { + // Remove notice listener to prevent memory leaks + if (this.noticeHandler) { + this.client.removeListener('notice', this.noticeHandler); + this.noticeHandler = null; + } + await this.client.end(); + this.client = null; + this.logger.info('Database connection closed'); + } + } + + /** + * Starts a transaction + */ + async beginTransaction(): Promise { + this.ensureConnected(); + await this.client!.query('BEGIN'); + this.logger.info('Transaction started'); + } + + /** + * Commits the transaction + */ + async commit(): Promise { + this.ensureConnected(); + await this.client!.query('COMMIT'); + this.logger.success('Transaction committed - all changes saved'); + } + + /** + * Rolls back the entire transaction + */ + async rollback(): Promise { + this.ensureConnected(); + try { + await this.client!.query('ROLLBACK'); + this.logger.warning('Transaction rolled back'); + } catch (error) { + this.logger.error('Failed to rollback transaction'); + throw error; + } + } + + /** + * Creates a savepoint + */ + async createSavepoint(name: string): Promise { + this.ensureConnected(); + const quotedName = quoteSqlIdentifier(name); + await this.client!.query(`SAVEPOINT ${quotedName}`); + this.logger.debug(`Savepoint created: ${name}`); + } + + /** + * Rolls back to a savepoint + */ + async rollbackToSavepoint(name: string): Promise { + this.ensureConnected(); + try { + const quotedName = quoteSqlIdentifier(name); + await this.client!.query(`ROLLBACK TO SAVEPOINT ${quotedName}`); + this.logger.warning(`Rolled back to savepoint: ${name}`); + return true; + } catch (_error) { + this.logger.error(`Failed to rollback to savepoint: ${name}`); + return false; + } + } + + /** + * Releases a savepoint + */ + async releaseSavepoint(name: string): Promise { + this.ensureConnected(); + const quotedName = quoteSqlIdentifier(name); + await this.client!.query(`RELEASE SAVEPOINT ${quotedName}`); + this.logger.debug(`Savepoint released: ${name}`); + } + + /** + * Executes a single SQL file with savepoint protection + */ + async executeFile( + filePath: string, + index: number + ): Promise { + this.ensureConnected(); + + const fileName = path.basename(filePath); + const savepointName = createSavepointName(fileName, index); + const startTime = Date.now(); + + this.logger.info(`Executing: ${fileName}`); + + // Read SQL content first so we can use it for error reporting + const sql = readSqlFile(filePath); + + try { + // Create savepoint before execution + await this.createSavepoint(savepointName); + + // Execute SQL + await this.client!.query(sql); + + // Release savepoint on success + await this.releaseSavepoint(savepointName); + + const durationMs = Date.now() - startTime; + this.logger.success(`Completed: ${fileName} (${durationMs}ms)`); + + return { + fileName, + filePath, + success: true, + durationMs, + savepointName, + }; + } catch (error) { + const durationMs = Date.now() - startTime; + const sqlError = this.formatError(error, fileName); + + this.logger.error(`Failed: ${fileName} (${durationMs}ms)`); + this.logger.error(`Error: ${sqlError.message}`); + + if (sqlError.code) { + this.logger.error(`PostgreSQL Error Code: ${sqlError.code}`); + } + if (sqlError.position) { + // Convert character position to line number for better debugging + const lineInfo = this.getLineFromPosition(sql, parseInt(sqlError.position, 10)); + this.logger.error(`Location: line ${lineInfo.line}, column ${lineInfo.column}`); + if (lineInfo.lineContent) { + this.logger.error(`Line content: ${lineInfo.lineContent}`); + // Show pointer to the error position + const pointer = ' '.repeat(lineInfo.column - 1) + '^'; + this.logger.error(` ${pointer}`); + } + } + if (sqlError.where) { + this.logger.error(`Context: ${sqlError.where}`); + } + if (sqlError.detail) { + this.logger.error(`Detail: ${sqlError.detail}`); + } + if (sqlError.hint) { + this.logger.info(`Hint: ${sqlError.hint}`); + } + + // Attempt rollback to savepoint + this.logger.warning(`Attempting rollback for: ${fileName}`); + const rollbackSuccess = await this.rollbackToSavepoint(savepointName); + + if (rollbackSuccess) { + this.logger.success(`Successfully rolled back: ${fileName}`); + } else { + this.logger.error( + `Failed to rollback: ${fileName} - database may be in inconsistent state` + ); + } + + return { + fileName, + filePath, + success: false, + durationMs, + savepointName, + error: sqlError, + rollbackSuccess, + }; + } + } + + /** + * Formats an error into SqlRunnerError + */ + private formatError(error: unknown, fileName?: string): SqlRunnerError { + if (error instanceof Error) { + const pgError = error as Error & { + code?: string; + detail?: string; + hint?: string; + position?: string; + where?: string; + }; + + return { + message: pgError.message, + code: pgError.code, + detail: pgError.detail, + hint: pgError.hint, + position: pgError.position, + where: pgError.where, + stack: pgError.stack, + fileName, + }; + } + + return { + message: String(error), + fileName, + }; + } + + /** + * Converts a character position to line number and column + * PostgreSQL error positions are 1-based character offsets + */ + private getLineFromPosition( + sql: string, + position: number + ): { line: number; column: number; lineContent: string } { + const lines = sql.split('\n'); + let currentPos = 0; + + for (let i = 0; i < lines.length; i++) { + const lineLength = lines[i].length + 1; // +1 for newline character + if (currentPos + lineLength >= position) { + return { + line: i + 1, // 1-based line number + column: position - currentPos, + lineContent: lines[i].trim(), + }; + } + currentPos += lineLength; + } + + // Fallback if position is beyond file end + return { + line: lines.length, + column: 1, + lineContent: lines[lines.length - 1]?.trim() || '', + }; + } + + /** + * Ensures database is connected + */ + private ensureConnected(): void { + if (!this.client) { + throw new Error('Database not connected. Call connect() first.'); + } + } +} diff --git a/src/core/file-scanner.ts b/src/core/file-scanner.ts new file mode 100644 index 0000000..d5649a7 --- /dev/null +++ b/src/core/file-scanner.ts @@ -0,0 +1,126 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * Options for scanning SQL files + */ +export interface FileScannerOptions { + /** Pattern to match SQL files */ + filePattern: RegExp; + /** Pattern for files to ignore */ + ignorePattern: RegExp; +} + +/** + * Result of scanning a directory for SQL files + */ +export interface FileScanResult { + /** Files to be executed (sorted) */ + files: string[]; + /** Files that were ignored */ + ignoredFiles: string[]; + /** Full paths to files */ + filePaths: string[]; +} + +/** + * Default file patterns + */ +export const DEFAULT_FILE_PATTERN = /\.sql$/; +export const DEFAULT_IGNORE_PATTERN = /^_ignored|README/; + +/** + * Scans a directory for SQL files to execute + * + * @param directory - Directory path to scan + * @param options - Scanning options + * @returns Scan result with files to execute and ignored files + * @throws Error if directory doesn't exist or is not readable + * + * @example + * ```ts + * const result = scanSqlFiles('./sql', { + * filePattern: /\.sql$/, + * ignorePattern: /^_ignored/ + * }); + * console.log(result.files); // ['00_setup.sql', '01_tables.sql', ...] + * ``` + */ +export function scanSqlFiles( + directory: string, + options: FileScannerOptions +): FileScanResult { + const resolvedDir = path.resolve(directory); + + // Validate directory exists + if (!fs.existsSync(resolvedDir)) { + throw new Error(`SQL directory not found: ${resolvedDir}`); + } + + // Validate it's a directory + const stats = fs.statSync(resolvedDir); + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${resolvedDir}`); + } + + // Read all files + const allEntries = fs.readdirSync(resolvedDir); + + // Filter for SQL files + const sqlFiles = allEntries.filter((file) => options.filePattern.test(file)); + + // Separate executable and ignored files + const executableFiles: string[] = []; + const ignoredFiles: string[] = []; + + for (const file of sqlFiles) { + if (options.ignorePattern.test(file)) { + ignoredFiles.push(file); + } else { + executableFiles.push(file); + } + } + + // Sort alphabetically for predictable execution order + executableFiles.sort(); + ignoredFiles.sort(); + + // Create full paths + const filePaths = executableFiles.map((file) => path.join(resolvedDir, file)); + + return { + files: executableFiles, + ignoredFiles, + filePaths, + }; +} + +/** + * Reads a SQL file's content + * + * @param filePath - Full path to the SQL file + * @returns File content as string + * @throws Error if file cannot be read + */ +export function readSqlFile(filePath: string): string { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`Failed to read SQL file: ${filePath}\n${message}`); + } +} + +/** + * Creates a valid savepoint name from a filename + * PostgreSQL savepoint names must be valid identifiers + * + * @param fileName - Original file name + * @param index - Index in execution order + * @returns Valid savepoint name + */ +export function createSavepointName(fileName: string, index: number): string { + // Replace non-alphanumeric characters with underscores + const sanitized = fileName.replace(/[^a-zA-Z0-9]/g, '_'); + return `sp_${sanitized}_${index}`; +} diff --git a/src/core/logger.ts b/src/core/logger.ts new file mode 100644 index 0000000..ced5624 --- /dev/null +++ b/src/core/logger.ts @@ -0,0 +1,167 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { Logger } from '../types.js'; +import { c, symbols } from '../ui/index.js'; + +/** + * Creates an ISO 8601 timestamp string for logging + * @returns Current timestamp in ISO format + */ +function getTimestamp(): string { + return new Date().toISOString(); +} + +/** + * Console logger with styled output and optional file logging + * + * Provides styled console output using the UI theme system + * and can optionally write logs to files. + * + * @example + * ```ts + * const logger = new ConsoleLogger({ logDirectory: './logs' }); + * logger.info('Starting process...'); + * logger.success('Operation completed!'); + * logger.error('Something went wrong'); + * ``` + */ +export class ConsoleLogger implements Logger { + private logDirectory: string | null; + private logFile: string | null = null; + private errorFile: string | null = null; + + /** + * Creates a new ConsoleLogger instance + * + * @param options - Logger configuration options + * @param options.logDirectory - Directory for log files, or null to disable file logging + */ + constructor(options: { logDirectory?: string | null } = {}) { + this.logDirectory = options.logDirectory ?? null; + + if (this.logDirectory) { + const dirCreated = this.ensureLogDirectory(); + if (dirCreated) { + this.logFile = path.join(this.logDirectory, 'sql-runner.log'); + this.errorFile = path.join(this.logDirectory, 'sql-runner-error.log'); + } else { + // Directory creation failed - disable file logging + this.logDirectory = null; + } + } + } + + /** Creates the log directory if it doesn't exist */ + private ensureLogDirectory(): boolean { + try { + if (this.logDirectory && !fs.existsSync(this.logDirectory)) { + fs.mkdirSync(this.logDirectory, { recursive: true }); + } + return true; + } catch { + // Failed to create log directory - file logging will be disabled + return false; + } + } + + /** Formats a log message with timestamp and level prefix for file logging */ + private formatFileMessage(level: string, message: string): string { + return `[${getTimestamp()}] [${level}] ${message}`; + } + + /** Appends a message to log files (main log and optionally error log) */ + private writeToFile(message: string, isError = false): void { + try { + if (this.logFile) { + fs.appendFileSync(this.logFile, `${message}\n`); + } + + if (isError && this.errorFile) { + fs.appendFileSync(this.errorFile, `${message}\n`); + } + } catch { + // Silently ignore file write errors (e.g., directory deleted mid-execution) + // Console output is still available, so the user won't lose information + } + } + + info(message: string): void { + console.log(`${c.info(symbols.info)} ${message}`); + this.writeToFile(this.formatFileMessage('INFO', message)); + } + + success(message: string): void { + console.log(`${c.success(symbols.success)} ${message}`); + this.writeToFile(this.formatFileMessage('SUCCESS', message)); + } + + warning(message: string): void { + console.log(`${c.warning(symbols.warning)} ${message}`); + this.writeToFile(this.formatFileMessage('WARNING', message)); + } + + error(message: string): void { + console.error(`${c.error(symbols.error)} ${message}`); + this.writeToFile(this.formatFileMessage('ERROR', message), true); + } + + debug(message: string): void { + console.log(`${c.muted(symbols.dot)} ${c.muted(message)}`); + this.writeToFile(this.formatFileMessage('DEBUG', message)); + } +} + +/** + * Silent logger that discards all messages + * + * Implements the Logger interface but produces no output. + * Useful for testing, CI environments, or when log output is not desired. + * + * @example + * ```ts + * const runner = new SqlRunner({ + * databaseUrl: process.env.DATABASE_URL, + * sqlDirectory: './sql', + * logger: new SilentLogger(), + * }); + * ``` + */ +export class SilentLogger implements Logger { + info(): void {} + success(): void {} + warning(): void {} + error(): void {} + debug(): void {} +} + +/** + * Factory function to create a logger based on configuration + * + * @param options - Logger factory options + * @param options.verbose - Enable verbose output (currently unused, reserved for future) + * @param options.logDirectory - Directory for log files, or null to disable file logging + * @param options.silent - If true, returns a SilentLogger that produces no output + * @returns A Logger instance configured according to the options + * + * @example + * ```ts + * // Normal logger with file logging + * const logger = createLogger({ logDirectory: './logs' }); + * + * // Silent logger for testing + * const silentLogger = createLogger({ silent: true }); + * ``` + */ +export function createLogger(options: { + verbose?: boolean; + logDirectory?: string | null; + silent?: boolean; +}): Logger { + if (options.silent) { + return new SilentLogger(); + } + + return new ConsoleLogger({ + logDirectory: options.logDirectory, + }); +} diff --git a/src/core/runner.ts b/src/core/runner.ts new file mode 100644 index 0000000..bab4363 --- /dev/null +++ b/src/core/runner.ts @@ -0,0 +1,458 @@ +import * as readline from 'node:readline'; +import type { + SqlRunnerConfig, + ResolvedSqlRunnerConfig, + ExecutionSummary, + FileExecutionResult, + Logger, + RunOptions, +} from '../types.js'; +import { + parseDatabaseUrl, + validateDatabaseUrl, + maskPassword, + getErrorMessage, +} from './connection.js'; +import { + scanSqlFiles, + DEFAULT_FILE_PATTERN, + DEFAULT_IGNORE_PATTERN, +} from './file-scanner.js'; +import { SqlExecutor } from './executor.js'; +import { createLogger } from './logger.js'; +import { createUIRenderer, c, type UIRenderer } from '../ui/index.js'; + +/** + * Default configuration values + */ +const DEFAULT_CONFIG: Partial = { + filePattern: DEFAULT_FILE_PATTERN, + ignorePattern: DEFAULT_IGNORE_PATTERN, + ssl: true, + requireConfirmation: true, + confirmationPhrase: 'CONFIRM', + verbose: false, + logDirectory: './logs', +}; + +/** + * Supabase SQL Dev Runner + * + * Executes SQL scripts sequentially on a Supabase/PostgreSQL database + * with transaction safety, savepoints, and automatic rollback. + * + * @example + * ```ts + * import { SqlRunner } from 'supabase-sql-dev-runner'; + * + * const runner = new SqlRunner({ + * databaseUrl: process.env.DATABASE_URL, + * sqlDirectory: './sql', + * }); + * + * const summary = await runner.run(); + * console.log(`Executed ${summary.successfulFiles} files`); + * ``` + */ +export class SqlRunner { + private config: ResolvedSqlRunnerConfig; + private logger: Logger; + private ui: UIRenderer; + private executor: SqlExecutor | null = null; + private abortRequested = false; + + constructor(config: SqlRunnerConfig) { + // Validate required config + const databaseUrl = validateDatabaseUrl(config.databaseUrl); + + // Merge with defaults + this.config = { + ...DEFAULT_CONFIG, + ...config, + databaseUrl, + filePattern: config.filePattern ?? DEFAULT_FILE_PATTERN, + ignorePattern: config.ignorePattern ?? DEFAULT_IGNORE_PATTERN, + ssl: config.ssl ?? true, + requireConfirmation: config.requireConfirmation ?? true, + confirmationPhrase: config.confirmationPhrase ?? 'CONFIRM', + verbose: config.verbose ?? false, + logDirectory: config.logDirectory === undefined ? './logs' : config.logDirectory, + } as ResolvedSqlRunnerConfig; + + // Set up logger (for file logging) + this.logger = + config.logger ?? + createLogger({ + verbose: this.config.verbose, + logDirectory: this.config.logDirectory, + }); + + // Set up UI renderer + this.ui = createUIRenderer({ + name: 'sql-runner', + version: '1.2.0', + }); + } + + /** + * Runs the SQL execution pipeline + * + * @param options - Additional run options + * @returns Execution summary with results + */ + async run(options: RunOptions = {}): Promise { + const startTime = Date.now(); + const results: FileExecutionResult[] = []; + + try { + // Parse connection config + const connectionConfig = parseDatabaseUrl(this.config.databaseUrl, this.config.ssl); + + // Render startup UI + this.ui.banner(); + this.ui.devWarning(); + + // Extract host from masked URL + const maskedUrl = maskPassword(this.config.databaseUrl); + const hostMatch = maskedUrl.match(/@([^:/]+)/); + const host = hostMatch ? hostMatch[1] : maskedUrl; + + // Scan for SQL files first to get count + const scanResult = scanSqlFiles(this.config.sqlDirectory, { + filePattern: this.config.filePattern, + ignorePattern: this.config.ignorePattern, + }); + + // Show connection info + this.ui.connectionInfo({ + host, + directory: this.config.sqlDirectory, + fileCount: scanResult.files.length, + logDirectory: this.config.logDirectory, + }); + + if (scanResult.files.length === 0) { + this.ui.warning('No SQL files found to execute'); + return this.createSummary(results, scanResult.ignoredFiles, startTime, false); + } + + // Filter files based on options + let filesToExecute = scanResult.filePaths; + let fileNames = scanResult.files; + + if (options.onlyFiles?.length) { + const onlySet = new Set(options.onlyFiles); + const filtered = scanResult.files + .map((f, i) => ({ name: f, path: scanResult.filePaths[i] })) + .filter((f) => onlySet.has(f.name)); + fileNames = filtered.map((f) => f.name); + filesToExecute = filtered.map((f) => f.path); + + // Warn about requested files that were not found + const foundSet = new Set(fileNames); + const notFound = options.onlyFiles.filter((f) => !foundSet.has(f)); + if (notFound.length > 0) { + this.ui.warning(`Requested files not found: ${notFound.join(', ')}`); + } + } + + if (options.skipFiles?.length) { + const skipSet = new Set(options.skipFiles); + const filtered = fileNames + .map((f, i) => ({ name: f, path: filesToExecute[i] })) + .filter((f) => !skipSet.has(f.name)); + fileNames = filtered.map((f) => f.name); + filesToExecute = filtered.map((f) => f.path); + } + + // Show files to execute + this.ui.fileList(fileNames); + + // Show ignored files + this.ui.ignoredFiles(scanResult.ignoredFiles); + + // Dry run mode + if (options.dryRun) { + this.ui.dryRun(); + return this.createSummary(results, scanResult.ignoredFiles, startTime, false); + } + + // Require confirmation unless skipped + if (this.config.requireConfirmation && !options.skipConfirmation) { + const confirmed = await this.requestConfirmation(); + if (!confirmed) { + this.ui.cancelled(); + return this.createSummary(results, scanResult.ignoredFiles, startTime, false); + } + } + + // Create executor and connect + this.executor = new SqlExecutor( + connectionConfig, + this.logger, + this.config.onNotice + ? (msg) => { + this.ui.sqlNotice(msg); + this.config.onNotice?.(msg); + } + : (msg) => this.ui.sqlNotice(msg) + ); + + this.ui.newline(); + this.ui.info('Connecting to database...'); + await this.executor.connect(); + await this.executor.beginTransaction(); + + // Set up SIGINT handler for graceful shutdown during execution + this.abortRequested = false; + const sigintHandler = () => { + if (!this.abortRequested) { + this.abortRequested = true; + this.ui.newline(); + this.ui.warning('Interrupt received - stopping after current file...'); + } + }; + process.on('SIGINT', sigintHandler); + + this.ui.newline(); + this.ui.info('Executing files...'); + this.ui.newline(); + + try { + // Execute files + for (let i = 0; i < filesToExecute.length; i++) { + // Check if abort was requested before starting next file + if (this.abortRequested) { + this.ui.warning('Execution aborted by user (Ctrl+C)'); + await this.executor.rollback(); + return this.createSummary(results, scanResult.ignoredFiles, startTime, false); + } + + const filePath = filesToExecute[i]; + const fileName = fileNames[i]; + + // Callback before file + this.config.onBeforeFile?.(fileName, i, filesToExecute.length); + + const result = await this.executor.executeFile(filePath, i); + results.push(result); + + // Show result + this.ui.fileResultSimple( + { + fileName, + success: result.success, + durationMs: result.durationMs, + error: result.error?.message, + }, + i, + filesToExecute.length + ); + + // Callback after file + this.config.onAfterFile?.(result); + + // Stop on failure + if (!result.success) { + this.ui.newline(); + this.ui.error({ + message: result.error?.message ?? 'Unknown error', + code: result.error?.code, + detail: result.error?.detail, + hint: result.error?.hint, + fileName, + }); + + if (this.config.logDirectory) { + this.ui.info(`Full error details saved to: ${this.config.logDirectory}/sql-runner-error.log`); + } + await this.executor.rollback(); + return this.createSummary(results, scanResult.ignoredFiles, startTime, false); + } + } + + // Check abort one more time after all files + if (this.abortRequested) { + this.ui.warning('Execution aborted by user (Ctrl+C)'); + await this.executor.rollback(); + return this.createSummary(results, scanResult.ignoredFiles, startTime, false); + } + } finally { + // Always remove SIGINT handler + process.removeListener('SIGINT', sigintHandler); + } + + // All successful - commit + await this.executor.commit(); + + const summary = this.createSummary(results, scanResult.ignoredFiles, startTime, true); + this.config.onComplete?.(summary); + + // Show summary + this.ui.summary({ + totalFiles: summary.totalFiles, + successfulFiles: summary.successfulFiles, + failedFiles: summary.failedFiles, + totalDurationMs: summary.totalDurationMs, + committed: summary.committed, + }); + + return summary; + } catch (error) { + // Handle unexpected errors + const errorMessage = getErrorMessage(error); + + // Extract PostgreSQL error details if available + const pgError = error as Error & { + code?: string; + detail?: string; + hint?: string; + }; + + const sqlError = { + message: errorMessage, + code: pgError.code, + detail: pgError.detail, + hint: pgError.hint, + stack: error instanceof Error ? error.stack : undefined, + }; + + this.config.onError?.(sqlError); + + this.ui.error({ + message: errorMessage, + code: pgError.code, + detail: pgError.detail, + hint: pgError.hint, + }); + + if (this.config.logDirectory) { + this.ui.info(`Full error details saved to: ${this.config.logDirectory}/sql-runner-error.log`); + } + + // Try to rollback + if (this.executor) { + try { + await this.executor.rollback(); + } catch { + this.ui.errorMessage('Failed to rollback transaction'); + } + } + + throw error; + } finally { + // Always disconnect + if (this.executor) { + await this.executor.disconnect(); + } + } + } + + /** + * Requests user confirmation before execution + * Handles readline errors and SIGINT gracefully + */ + private async requestConfirmation(): Promise { + // Check if stdin is a TTY (interactive terminal) + if (!process.stdin.isTTY) { + this.ui.warning('Non-interactive mode detected. Use -y to skip confirmation.'); + return false; + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + this.ui.newline(); + this.ui.confirmationWarning(); + + return new Promise((resolve) => { + let answered = false; + + // Handle readline errors + rl.on('error', () => { + if (!answered) { + answered = true; + rl.close(); + resolve(false); + } + }); + + // Handle SIGINT (Ctrl+C) during confirmation + rl.on('SIGINT', () => { + if (!answered) { + answered = true; + this.ui.newline(); + rl.close(); + resolve(false); + } + }); + + // Handle stream close (e.g., piped input ends) + rl.on('close', () => { + if (!answered) { + answered = true; + resolve(false); + } + }); + + rl.question( + `${c.muted('Type')} ${c.highlight(`"${this.config.confirmationPhrase}"`)} ${c.muted('to proceed:')} `, + (answer) => { + if (!answered) { + answered = true; + rl.close(); + resolve(answer === this.config.confirmationPhrase); + } + } + ); + }); + } + + /** + * Creates an execution summary + */ + private createSummary( + results: FileExecutionResult[], + ignoredFiles: string[], + startTime: number, + committed: boolean + ): ExecutionSummary { + const successfulFiles = results.filter((r) => r.success).length; + const failedFiles = results.filter((r) => !r.success).length; + + return { + totalFiles: results.length, + successfulFiles, + failedFiles, + totalDurationMs: Date.now() - startTime, + results, + allSuccessful: failedFiles === 0, + committed, + ignoredFiles, + }; + } +} + +/** + * Creates and runs the SQL runner with the given config + * Convenience function for simple usage + * + * @example + * ```ts + * import { runSqlScripts } from 'supabase-sql-dev-runner'; + * + * const summary = await runSqlScripts({ + * databaseUrl: process.env.DATABASE_URL, + * sqlDirectory: './sql', + * }); + * ``` + */ +export async function runSqlScripts( + config: SqlRunnerConfig, + options?: RunOptions +): Promise { + const runner = new SqlRunner(config); + return runner.run(options); +} diff --git a/src/core/watcher.ts b/src/core/watcher.ts new file mode 100644 index 0000000..9bc6ea7 --- /dev/null +++ b/src/core/watcher.ts @@ -0,0 +1,134 @@ +import * as fs from 'node:fs'; +import { getErrorMessage } from './connection.js'; +import { c, symbols } from '../ui/index.js'; + +/** + * Options for the file watcher + */ +export interface WatchOptions { + /** Directory to watch for changes */ + directory: string; + /** Pattern to match files (e.g., /\.sql$/) */ + pattern: RegExp; + /** Seconds to wait before execution (default: 30) */ + countdownSeconds: number; + /** Callback to execute when countdown completes */ + onExecute: () => Promise; + /** Logger for output */ + logger: { + info: (msg: string) => void; + warning: (msg: string) => void; + }; +} + +/** + * Starts watching a directory for file changes with countdown + * + * @param options - Watch configuration + * @returns Cleanup function to stop watching + * + * @example + * ```ts + * const cleanup = startWatcher({ + * directory: './sql', + * pattern: /\.sql$/, + * countdownSeconds: 30, + * onExecute: async () => { await runner.run(); }, + * logger: console, + * }); + * + * // Later: cleanup(); + * ``` + */ +export function startWatcher(options: WatchOptions): () => void { + let countdownTimer: ReturnType | null = null; + let countdownInterval: ReturnType | null = null; + let secondsRemaining = 0; + let isExecuting = false; + + /** + * Clears any active countdown timers + */ + const clearCountdown = (): void => { + if (countdownTimer) { + clearTimeout(countdownTimer); + countdownTimer = null; + } + if (countdownInterval) { + clearInterval(countdownInterval); + countdownInterval = null; + } + }; + + /** + * Starts or resets the countdown timer + */ + const startCountdown = (): void => { + // Don't start countdown if currently executing + if (isExecuting) { + options.logger.info('Execution in progress, change queued...'); + return; + } + + clearCountdown(); + secondsRemaining = options.countdownSeconds; + + // Show initial countdown + process.stdout.write( + `\r${c.muted(`${symbols.pending} Running in ${secondsRemaining}s... (save again to reset)`)} ` + ); + + // Update countdown display every second + countdownInterval = setInterval(() => { + secondsRemaining--; + if (secondsRemaining > 0) { + process.stdout.write( + `\r${c.muted(`${symbols.pending} Running in ${secondsRemaining}s... (save again to reset)`)} ` + ); + } + }, 1000); + + // Execute after countdown completes + countdownTimer = setTimeout(async () => { + clearCountdown(); + + // Clear the countdown line and show execution message + process.stdout.write('\r \r'); + console.log(`${c.primary(symbols.arrowRight)} Executing SQL files...\n`); + + isExecuting = true; + try { + await options.onExecute(); + } catch (error) { + options.logger.warning(`Execution error: ${getErrorMessage(error)}`); + } finally { + isExecuting = false; + } + + console.log(''); + options.logger.info(`${c.primary(symbols.running)} Watching for changes... ${c.muted('(Ctrl+C to stop)')}`); + }, options.countdownSeconds * 1000); + }; + + // Start watching the directory + const watcher = fs.watch(options.directory, (_eventType, filename) => { + // Only react to files matching the pattern + if (filename && options.pattern.test(filename)) { + // Clear line and show which file changed + process.stdout.write('\r \r'); + options.logger.info(`Changed: ${c.cyan(filename)}`); + startCountdown(); + } + }); + + // Handle watcher errors + watcher.on('error', (error) => { + options.logger.warning(`Watch error: ${error.message}`); + }); + + // Return cleanup function + return (): void => { + clearCountdown(); + watcher.close(); + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6f0881a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,81 @@ +/** + * Supabase SQL Dev Runner + * + * Execute SQL scripts sequentially on Supabase PostgreSQL with + * transaction safety, savepoints, and automatic rollback. + * + * @packageDocumentation + */ + +// Main exports +export { SqlRunner, runSqlScripts } from './core/runner.js'; + +// Types +export type { + SqlRunnerConfig, + Logger, + FileExecutionResult, + ExecutionSummary, + SqlRunnerError, + ConnectionConfig, + RunOptions, + SqlRunnerEvent, +} from './types.js'; + +// Connection utilities +export { + parseDatabaseUrl, + maskPassword, + validateDatabaseUrl, + getErrorMessage, +} from './core/connection.js'; + +// Error handling (new SOLID architecture) +export { + // Main functions + getConnectionErrorHelp, + formatConnectionErrorHelp, + // Main class + ConnectionErrorHandler, + // Registry + DefaultErrorDetectorRegistry, + createDefaultRegistry, + // Formatters + ConsoleErrorFormatter, + SimpleErrorFormatter, + JsonErrorFormatter, + MarkdownErrorFormatter, + createDefaultFormatter, + // Base detector for extension + BaseErrorDetector, +} from './core/errors/index.js'; + +// Error handling types +export type { + ConnectionContext, + ErrorHelp, + ErrorHelp as ConnectionErrorHelp, // Backwards compatibility alias + ErrorDetector, + ErrorFormatter, + ErrorDetectorRegistry, + ErrorHandlerOptions, +} from './core/errors/index.js'; + +// File scanner utilities +export { + scanSqlFiles, + readSqlFile, + createSavepointName, + DEFAULT_FILE_PATTERN, + DEFAULT_IGNORE_PATTERN, +} from './core/file-scanner.js'; + +// Logger utilities +export { ConsoleLogger, SilentLogger, createLogger } from './core/logger.js'; + +// Executor (for advanced usage) +export { SqlExecutor } from './core/executor.js'; + +// Watcher +export { startWatcher } from './core/watcher.js'; +export type { WatchOptions } from './core/watcher.js'; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..a50dbd2 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,236 @@ +/** + * Configuration options for the SQL runner + */ +export interface SqlRunnerConfig { + /** + * Supabase/PostgreSQL database URL + * Format: postgres://user:password@host:port/database + */ + databaseUrl: string; + + /** + * Directory containing SQL files to execute + * Files are executed in alphabetical order (00_, 01_, etc.) + */ + sqlDirectory: string; + + /** + * Optional file pattern to filter SQL files + * @default /\.sql$/ + */ + filePattern?: RegExp; + + /** + * Files matching this pattern will be ignored + * @default /^_ignored|README/ + */ + ignorePattern?: RegExp; + + /** + * Enable SSL for database connection + * @default true + */ + ssl?: boolean | { rejectUnauthorized: boolean }; + + /** + * Require human confirmation before executing + * Set to false for CI/CD or automated pipelines + * @default true + */ + requireConfirmation?: boolean; + + /** + * Custom confirmation phrase (if requireConfirmation is true) + * @default "CONFIRM" + */ + confirmationPhrase?: string; + + /** + * Enable verbose logging + * @default false + */ + verbose?: boolean; + + /** + * Custom logger function + * If not provided, uses console with colors + */ + logger?: Logger; + + /** + * Directory to save execution logs + * Set to null to disable file logging + * @default './logs' + */ + logDirectory?: string | null; + + /** + * Callback for SQL NOTICE messages + */ + onNotice?: (message: string) => void; + + /** + * Callback before each file execution + */ + onBeforeFile?: (fileName: string, index: number, total: number) => void; + + /** + * Callback after each file execution + */ + onAfterFile?: (result: FileExecutionResult) => void; + + /** + * Callback when execution completes + */ + onComplete?: (summary: ExecutionSummary) => void; + + /** + * Callback when an error occurs + */ + onError?: (error: SqlRunnerError) => void; +} + +/** + * Logger interface for custom logging implementations + */ +export interface Logger { + info(message: string): void; + success(message: string): void; + warning(message: string): void; + error(message: string): void; + debug(message: string): void; +} + +/** + * Result of executing a single SQL file + */ +export interface FileExecutionResult { + /** File name that was executed */ + fileName: string; + /** Full path to the file */ + filePath: string; + /** Whether execution succeeded */ + success: boolean; + /** Execution duration in milliseconds */ + durationMs: number; + /** Savepoint name used for this file */ + savepointName: string; + /** Error details if execution failed */ + error?: SqlRunnerError; + /** Whether rollback was successful (if error occurred) */ + rollbackSuccess?: boolean; +} + +/** + * Summary of the entire execution run + */ +export interface ExecutionSummary { + /** Total files processed */ + totalFiles: number; + /** Successfully executed files */ + successfulFiles: number; + /** Failed files */ + failedFiles: number; + /** Total execution time in milliseconds */ + totalDurationMs: number; + /** Individual file results */ + results: FileExecutionResult[]; + /** Whether all files succeeded */ + allSuccessful: boolean; + /** Whether the transaction was committed */ + committed: boolean; + /** Files that were skipped/ignored */ + ignoredFiles: string[]; +} + +/** + * Extended error class for SQL runner errors + */ +export interface SqlRunnerError { + /** Error message */ + message: string; + /** PostgreSQL error code */ + code?: string; + /** Additional error detail */ + detail?: string; + /** Hint for resolving the error */ + hint?: string; + /** Position in SQL where error occurred */ + position?: string; + /** Context/location where error occurred */ + where?: string; + /** Original error stack */ + stack?: string; + /** File that caused the error */ + fileName?: string; +} + +/** + * Database connection configuration parsed from URL + */ +export interface ConnectionConfig { + host: string; + port: number; + database: string; + user: string; + password: string; + ssl: boolean | { rejectUnauthorized: boolean }; +} + +/** + * Options for the run method + */ +export interface RunOptions { + /** + * Skip the confirmation prompt + * Useful for CI/CD environments + */ + skipConfirmation?: boolean; + + /** + * Only run specific files (by name) + */ + onlyFiles?: string[]; + + /** + * Skip specific files (by name) + */ + skipFiles?: string[]; + + /** + * Dry run - show what would be executed without running + */ + dryRun?: boolean; +} + +/** + * Events emitted by the SQL runner + */ +export type SqlRunnerEvent = + | { type: 'connected' } + | { type: 'transaction_started' } + | { type: 'file_start'; fileName: string; index: number; total: number } + | { type: 'file_complete'; result: FileExecutionResult } + | { type: 'notice'; message: string } + | { type: 'transaction_committed' } + | { type: 'transaction_rolled_back' } + | { type: 'complete'; summary: ExecutionSummary } + | { type: 'error'; error: SqlRunnerError }; + +/** + * Callback function names in SqlRunnerConfig (always optional) + */ +type SqlRunnerCallbacks = + | 'onNotice' + | 'onBeforeFile' + | 'onAfterFile' + | 'onComplete' + | 'onError' + | 'logger'; + +/** + * Internal resolved configuration with all non-callback fields required + * Callbacks and logger remain optional as they are user-provided + */ +export type ResolvedSqlRunnerConfig = Required> & + Pick; diff --git a/src/ui/components/banner.ts b/src/ui/components/banner.ts new file mode 100644 index 0000000..7ced4b2 --- /dev/null +++ b/src/ui/components/banner.ts @@ -0,0 +1,90 @@ +/** + * Banner Component + * + * Renders the startup banner with tool name and version. + * Single responsibility: display application identity. + */ + +import { symbols, c, visibleLength } from '../theme.js'; + +export interface BannerOptions { + name: string; + version: string; + subtitle?: string; + width?: number; +} + +/** + * Renders a startup banner with rounded box + * + * @example + * ``` + * ╭──────────────────────────────────────────╮ + * │ │ + * │ ▸ sql-runner v1.2.0 │ + * │ Supabase SQL Dev Runner │ + * │ │ + * ╰──────────────────────────────────────────╯ + * ``` + */ +export function renderBanner(options: BannerOptions): string { + const { name, version, subtitle } = options; + const width = options.width ?? 44; + const innerWidth = width - 4; // Account for border and padding + + const lines: string[] = []; + + // Top border + lines.push( + c.muted(`${symbols.topLeft}${symbols.horizontal.repeat(width - 2)}${symbols.topRight}`) + ); + + // Empty line + lines.push(c.muted(symbols.vertical) + ' '.repeat(width - 2) + c.muted(symbols.vertical)); + + // Title line: ▸ sql-runner v1.2.0 + const titleContent = `${c.primary(symbols.arrowRight)} ${c.title(name)} ${c.muted(`v${version}`)}`; + const titlePadding = innerWidth - visibleLength(titleContent); + lines.push( + c.muted(symbols.vertical) + + ' ' + + titleContent + + ' '.repeat(Math.max(0, titlePadding)) + + c.muted(symbols.vertical) + ); + + // Subtitle line + if (subtitle) { + const subtitleContent = c.subtitle(subtitle); + const subtitlePadding = innerWidth - visibleLength(subtitleContent); + lines.push( + c.muted(symbols.vertical) + + ' ' + + subtitleContent + + ' '.repeat(Math.max(0, subtitlePadding - 2)) + + c.muted(symbols.vertical) + ); + } + + // Empty line + lines.push(c.muted(symbols.vertical) + ' '.repeat(width - 2) + c.muted(symbols.vertical)); + + // Bottom border + lines.push( + c.muted(`${symbols.bottomLeft}${symbols.horizontal.repeat(width - 2)}${symbols.bottomRight}`) + ); + + return lines.join('\n'); +} + +/** + * Renders a minimal banner (just name and version) + * + * @example + * ``` + * ▸ sql-runner v1.2.0 + * ``` + */ +export function renderMinimalBanner(name: string, version: string): string { + return `${c.primary(symbols.arrowRight)} ${c.title(name)} ${c.muted(`v${version}`)}`; +} diff --git a/src/ui/components/box.ts b/src/ui/components/box.ts new file mode 100644 index 0000000..528fc91 --- /dev/null +++ b/src/ui/components/box.ts @@ -0,0 +1,162 @@ +/** + * Box Component + * + * Reusable box drawing utility for bordered content. + * Single responsibility: render content within borders. + */ + +import { symbols, c, visibleLength } from '../theme.js'; + +export type BoxStyle = 'rounded' | 'sharp' | 'double' | 'none'; + +export interface BoxOptions { + style?: BoxStyle; + padding?: number; + width?: number; + title?: string; + titleAlign?: 'left' | 'center' | 'right'; +} + +interface BoxChars { + topLeft: string; + topRight: string; + bottomLeft: string; + bottomRight: string; + horizontal: string; + vertical: string; +} + +const boxStyles: Record = { + rounded: { + topLeft: symbols.topLeft, + topRight: symbols.topRight, + bottomLeft: symbols.bottomLeft, + bottomRight: symbols.bottomRight, + horizontal: symbols.horizontal, + vertical: symbols.vertical, + }, + sharp: { + topLeft: symbols.sharpTopLeft, + topRight: symbols.sharpTopRight, + bottomLeft: symbols.sharpBottomLeft, + bottomRight: symbols.sharpBottomRight, + horizontal: symbols.sharpHorizontal, + vertical: symbols.sharpVertical, + }, + double: { + topLeft: '╔', + topRight: '╗', + bottomLeft: '╚', + bottomRight: '╝', + horizontal: '═', + vertical: '║', + }, + none: { + topLeft: ' ', + topRight: ' ', + bottomLeft: ' ', + bottomRight: ' ', + horizontal: ' ', + vertical: ' ', + }, +}; + +/** + * Renders content within a box + * + * @example + * ``` + * ╭────────────────────╮ + * │ Content goes here │ + * ╰────────────────────╯ + * ``` + */ +export function renderBox(content: string | string[], options: BoxOptions = {}): string { + const { style = 'rounded', padding = 1, title, titleAlign = 'left' } = options; + const chars = boxStyles[style]; + const lines = Array.isArray(content) ? content : content.split('\n'); + + // Calculate width + const contentWidth = Math.max(...lines.map((line) => visibleLength(line))); + const innerWidth = options.width ?? contentWidth + padding * 2; + + const result: string[] = []; + + // Top border with optional title + if (title) { + const titleText = ` ${title} `; + const titleLen = visibleLength(titleText); + const remainingWidth = innerWidth - titleLen; + + let topBorder: string; + if (titleAlign === 'center') { + const leftPad = Math.floor(remainingWidth / 2); + const rightPad = remainingWidth - leftPad; + topBorder = + chars.topLeft + + chars.horizontal.repeat(leftPad) + + c.muted(titleText) + + chars.horizontal.repeat(rightPad) + + chars.topRight; + } else if (titleAlign === 'right') { + topBorder = + chars.topLeft + + chars.horizontal.repeat(remainingWidth) + + c.muted(titleText) + + chars.topRight; + } else { + topBorder = + chars.topLeft + + c.muted(titleText) + + chars.horizontal.repeat(remainingWidth) + + chars.topRight; + } + result.push(c.muted(topBorder)); + } else { + result.push(c.muted(chars.topLeft + chars.horizontal.repeat(innerWidth) + chars.topRight)); + } + + // Content lines + for (const line of lines) { + const lineLen = visibleLength(line); + const rightPad = innerWidth - padding - lineLen; + result.push( + c.muted(chars.vertical) + + ' '.repeat(padding) + + line + + ' '.repeat(Math.max(0, rightPad)) + + c.muted(chars.vertical) + ); + } + + // Bottom border + result.push(c.muted(chars.bottomLeft + chars.horizontal.repeat(innerWidth) + chars.bottomRight)); + + return result.join('\n'); +} + +/** + * Renders a horizontal divider line + * + * @example + * ``` + * ──────────────────────────── + * ``` + */ +export function renderDivider(width = 40, char = symbols.horizontal): string { + return c.muted(char.repeat(width)); +} + +/** + * Renders a section header with line + * + * @example + * ``` + * ── Section Title ────────────── + * ``` + */ +export function renderSectionHeader(title: string, width = 40): string { + const prefix = `${symbols.horizontal}${symbols.horizontal} `; + const suffix = ` ${symbols.horizontal.repeat(Math.max(0, width - visibleLength(title) - 4))}`; + return c.muted(prefix) + c.title(title) + c.muted(suffix); +} diff --git a/src/ui/components/spinner.ts b/src/ui/components/spinner.ts new file mode 100644 index 0000000..f07e5cb --- /dev/null +++ b/src/ui/components/spinner.ts @@ -0,0 +1,164 @@ +/** + * Spinner Component + * + * Animated progress indicator for long-running operations. + * Single responsibility: show activity feedback. + */ + +import { spinnerFrames, c } from '../theme.js'; + +export interface SpinnerOptions { + text?: string; + frames?: readonly string[]; + interval?: number; + stream?: NodeJS.WriteStream; +} + +/** + * Creates an animated spinner instance + * + * @example + * ```ts + * const spinner = createSpinner({ text: 'Loading...' }); + * spinner.start(); + * // ... do work + * spinner.stop(); + * ``` + */ +export function createSpinner(options: SpinnerOptions = {}) { + const { + text = '', + frames = spinnerFrames, + interval = 80, + stream = process.stdout, + } = options; + + let frameIndex = 0; + let timer: ReturnType | null = null; + let currentText = text; + let isSpinning = false; + + const clearLine = (): void => { + stream.clearLine?.(0); + stream.cursorTo?.(0); + }; + + const render = (): void => { + const frame = c.primary(frames[frameIndex]); + clearLine(); + stream.write(`${frame} ${currentText}`); + frameIndex = (frameIndex + 1) % frames.length; + }; + + return { + /** + * Start the spinner animation + */ + start(newText?: string): void { + if (isSpinning) return; + if (newText) currentText = newText; + isSpinning = true; + + // Hide cursor + stream.write('\x1b[?25l'); + + render(); + timer = setInterval(render, interval); + }, + + /** + * Stop the spinner and clear the line + */ + stop(): void { + if (!isSpinning) return; + isSpinning = false; + + if (timer) { + clearInterval(timer); + timer = null; + } + + clearLine(); + // Show cursor + stream.write('\x1b[?25h'); + }, + + /** + * Stop with a success message + */ + success(message?: string): void { + this.stop(); + if (message) { + stream.write(`${c.success('✓')} ${message}\n`); + } + }, + + /** + * Stop with an error message + */ + error(message?: string): void { + this.stop(); + if (message) { + stream.write(`${c.error('✗')} ${message}\n`); + } + }, + + /** + * Stop with a warning message + */ + warn(message?: string): void { + this.stop(); + if (message) { + stream.write(`${c.warning('⚠')} ${message}\n`); + } + }, + + /** + * Update the spinner text + */ + update(newText: string): void { + currentText = newText; + if (isSpinning) { + render(); + } + }, + + /** + * Check if spinner is currently active + */ + isActive(): boolean { + return isSpinning; + }, + }; +} + +/** + * Simple inline progress indicator (no animation) + * + * @example + * ``` + * ● Connecting to database... + * ``` + */ +export function renderProgress(text: string, status: 'active' | 'done' | 'error' = 'active'): string { + switch (status) { + case 'done': + return `${c.success('✓')} ${text}`; + case 'error': + return `${c.error('✗')} ${text}`; + default: + return `${c.primary('●')} ${text}`; + } +} + +/** + * Countdown display for watch mode + * + * @example + * ``` + * ⏳ Running in 5s... (save again to reset) + * ``` + */ +export function renderCountdown(seconds: number): string { + return c.muted(`⏳ Running in ${seconds}s... (save again to reset)`); +} diff --git a/src/ui/components/table.ts b/src/ui/components/table.ts new file mode 100644 index 0000000..9a30624 --- /dev/null +++ b/src/ui/components/table.ts @@ -0,0 +1,226 @@ +/** + * Table Component + * + * Renders data in aligned columns with optional borders. + * Single responsibility: tabular data display. + */ + +import { symbols, c, visibleLength } from '../theme.js'; + +export interface Column { + key: string; + label?: string; + width?: number; + align?: 'left' | 'center' | 'right'; +} + +export interface TableOptions { + columns: Column[]; + showHeader?: boolean; + showBorder?: boolean; + compact?: boolean; +} + +/** + * Pads a string to a specific width + */ +function padString(str: string, width: number, align: 'left' | 'center' | 'right' = 'left'): string { + const len = visibleLength(str); + const padding = Math.max(0, width - len); + + if (align === 'center') { + const leftPad = Math.floor(padding / 2); + const rightPad = padding - leftPad; + return ' '.repeat(leftPad) + str + ' '.repeat(rightPad); + } else if (align === 'right') { + return ' '.repeat(padding) + str; + } + return str + ' '.repeat(padding); +} + +/** + * Renders a data table with columns + * + * @example + * ``` + * ┌──────────────────────┬──────────┐ + * │ File │ Status │ + * ├──────────────────────┼──────────┤ + * │ 01_tables.sql │ ✓ 12ms │ + * │ 02_functions.sql │ ✓ 45ms │ + * └──────────────────────┴──────────┘ + * ``` + */ +export function renderTable( + data: Record[], + options: TableOptions +): string { + const { columns, showHeader = true, showBorder = true, compact = false } = options; + + // Calculate column widths + const colWidths: number[] = columns.map((col) => { + const headerWidth = visibleLength(col.label ?? col.key); + const maxDataWidth = Math.max( + ...data.map((row) => visibleLength(row[col.key] ?? '')) + ); + return col.width ?? Math.max(headerWidth, maxDataWidth); + }); + + const lines: string[] = []; + const totalWidth = colWidths.reduce((a, b) => a + b, 0) + (colWidths.length - 1) * 3 + 4; + + if (showBorder) { + // Top border + const topBorder = + symbols.sharpTopLeft + + colWidths.map((w) => symbols.sharpHorizontal.repeat(w + 2)).join(symbols.sharpTeeDown) + + symbols.sharpTopRight; + lines.push(c.muted(topBorder)); + } + + // Header row + if (showHeader) { + const headerCells = columns.map((col, i) => + padString(c.muted(col.label ?? col.key), colWidths[i], col.align) + ); + + if (showBorder) { + lines.push( + c.muted(symbols.sharpVertical) + + ' ' + + headerCells.join(c.muted(' ' + symbols.sharpVertical + ' ')) + + ' ' + + c.muted(symbols.sharpVertical) + ); + + // Header separator + const separator = + symbols.sharpTeeRight + + colWidths.map((w) => symbols.sharpHorizontal.repeat(w + 2)).join(symbols.sharpCross) + + symbols.sharpTeeLeft; + lines.push(c.muted(separator)); + } else { + lines.push(headerCells.join(' ')); + if (!compact) { + lines.push(c.muted(symbols.horizontal.repeat(totalWidth - 4))); + } + } + } + + // Data rows + for (const row of data) { + const cells = columns.map((col, i) => + padString(row[col.key] ?? '', colWidths[i], col.align) + ); + + if (showBorder) { + lines.push( + c.muted(symbols.sharpVertical) + + ' ' + + cells.join(c.muted(' ' + symbols.sharpVertical + ' ')) + + ' ' + + c.muted(symbols.sharpVertical) + ); + } else { + lines.push(cells.join(' ')); + } + } + + if (showBorder) { + // Bottom border + const bottomBorder = + symbols.sharpBottomLeft + + colWidths.map((w) => symbols.sharpHorizontal.repeat(w + 2)).join(symbols.sharpTeeUp) + + symbols.sharpBottomRight; + lines.push(c.muted(bottomBorder)); + } + + return lines.join('\n'); +} + +/** + * Renders a simple list with bullets + * + * @example + * ``` + * • 01_extensions.sql + * • 02_tables.sql + * • 03_functions.sql + * ``` + */ +export function renderList(items: string[], bullet = symbols.bullet): string { + return items.map((item) => `${c.muted(bullet)} ${item}`).join('\n'); +} + +/** + * Renders key-value pairs aligned + * + * @example + * ``` + * Database: aws-0-us-east-1.pooler.supabase.com + * Directory: ./sql + * Files: 6 found + * ``` + */ +export function renderKeyValue( + pairs: Array<{ key: string; value: string }>, + options: { separator?: string; keyWidth?: number } = {} +): string { + const { separator = ':', keyWidth } = options; + + // Calculate max key width + const maxKeyLen = keyWidth ?? Math.max(...pairs.map((p) => p.key.length)); + + return pairs + .map((pair) => { + const paddedKey = pair.key.padEnd(maxKeyLen); + return `${c.label(paddedKey)}${c.muted(separator)} ${c.value(pair.value)}`; + }) + .join('\n'); +} + +/** + * Renders a file list with status indicators + * + * @example + * ``` + * 01_extensions.sql ✓ 12ms + * 02_tables.sql ✓ 45ms + * 03_functions.sql ● running + * ``` + */ +export function renderFileStatus( + files: Array<{ + name: string; + status: 'pending' | 'running' | 'success' | 'error'; + duration?: number; + error?: string; + }>, + options: { nameWidth?: number } = {} +): string { + const maxNameLen = options.nameWidth ?? Math.max(...files.map((f) => f.name.length)); + + return files + .map((file) => { + const paddedName = file.name.padEnd(maxNameLen); + let statusStr: string; + + switch (file.status) { + case 'pending': + statusStr = c.muted(`${symbols.pending} pending`); + break; + case 'running': + statusStr = c.primary(`${symbols.running} running`); + break; + case 'success': + statusStr = c.success(`${symbols.success} ${file.duration}ms`); + break; + case 'error': + statusStr = c.error(`${symbols.error} failed`); + break; + } + + return ` ${paddedName} ${statusStr}`; + }) + .join('\n'); +} diff --git a/src/ui/index.ts b/src/ui/index.ts new file mode 100644 index 0000000..8c561e0 --- /dev/null +++ b/src/ui/index.ts @@ -0,0 +1,71 @@ +/** + * UI Module - Public Exports + * + * Central export point for all UI components and utilities. + * + * This module provides: + * - Theme system (colors, symbols, styling utilities) + * - Reusable UI components (banner, box, table, spinner) + * - UIRenderer facade for coordinated console output + * + * @example + * ```ts + * import { c, symbols, renderBanner, createUIRenderer } from 'supabase-sql-dev-runner/ui'; + * + * // Use color utilities + * console.log(c.success('Operation completed')); + * + * // Render a banner + * console.log(renderBanner({ name: 'my-app', version: '1.0.0' })); + * + * // Use the full renderer + * const ui = createUIRenderer({ name: 'my-app', version: '1.0.0' }); + * ui.banner(); + * ui.info('Starting...'); + * ``` + * + * @packageDocumentation + */ + +// Theme and styling +export { + colors, + theme, + symbols, + spinnerFrames, + spinnerStyles, + supportsColor, + stripAnsi, + visibleLength, + colorize, + c, +} from './theme.js'; + +// Components - Banner +export { renderBanner, renderMinimalBanner } from './components/banner.js'; +export type { BannerOptions } from './components/banner.js'; + +// Components - Box +export { renderBox, renderDivider, renderSectionHeader } from './components/box.js'; +export type { BoxStyle, BoxOptions } from './components/box.js'; + +// Components - Table +export { + renderTable, + renderList, + renderKeyValue, + renderFileStatus, +} from './components/table.js'; +export type { Column, TableOptions } from './components/table.js'; + +// Components - Spinner +export { createSpinner, renderProgress, renderCountdown } from './components/spinner.js'; +export type { SpinnerOptions } from './components/spinner.js'; + +// Main renderer (facade) +export { UIRenderer, createUIRenderer } from './renderer.js'; +export type { + UIRendererOptions, + FileResult, + ExecutionSummaryData, +} from './renderer.js'; diff --git a/src/ui/renderer.ts b/src/ui/renderer.ts new file mode 100644 index 0000000..1cde0b9 --- /dev/null +++ b/src/ui/renderer.ts @@ -0,0 +1,396 @@ +/** + * UI Renderer - Facade for all UI components + * + * Provides a unified interface for console output, coordinating + * all UI components. Follows the Facade pattern. + */ + +import { symbols, c } from './theme.js'; +import { renderBanner, renderMinimalBanner } from './components/banner.js'; +import { renderBox, renderDivider } from './components/box.js'; +import { renderList, renderKeyValue } from './components/table.js'; +import { createSpinner } from './components/spinner.js'; + +export interface UIRendererOptions { + /** Tool name */ + name?: string; + /** Tool version */ + version?: string; + /** Output stream */ + stream?: NodeJS.WriteStream; + /** Disable output */ + silent?: boolean; +} + +export interface FileResult { + fileName: string; + success: boolean; + durationMs?: number; + error?: string; +} + +export interface ExecutionSummaryData { + totalFiles: number; + successfulFiles: number; + failedFiles: number; + totalDurationMs: number; + committed: boolean; +} + +/** + * Main UI renderer class + * + * Coordinates all UI components and provides a clean API + * for rendering console output. + */ +export class UIRenderer { + private name: string; + private version: string; + private stream: NodeJS.WriteStream; + private silent: boolean; + + constructor(options: UIRendererOptions = {}) { + this.name = options.name ?? 'sql-runner'; + this.version = options.version ?? '1.0.0'; + this.stream = options.stream ?? process.stdout; + this.silent = options.silent ?? false; + } + + /** + * Write to output stream + */ + private write(text: string): void { + if (!this.silent) { + this.stream.write(text); + } + } + + /** + * Write line to output stream + */ + private writeln(text = ''): void { + this.write(text + '\n'); + } + + /** + * Render startup banner + */ + banner(): void { + this.writeln(); + this.writeln( + renderBanner({ + name: this.name, + version: this.version, + subtitle: 'Supabase SQL Dev Runner', + }) + ); + } + + /** + * Render minimal banner (single line) + */ + minimalBanner(): void { + this.writeln(); + this.writeln(renderMinimalBanner(this.name, this.version)); + } + + /** + * Render development warning + */ + devWarning(): void { + this.writeln(); + this.writeln(`${c.warning(symbols.warning)} ${c.warning('Development tool')} ${c.muted('- not for production use')}`); + } + + /** + * Render connection info + */ + connectionInfo(data: { + host: string; + directory: string; + fileCount: number; + logDirectory?: string | null; + }): void { + this.writeln(); + + const pairs = [ + { key: 'Database', value: data.host }, + { key: 'Directory', value: data.directory }, + { key: 'Files', value: `${data.fileCount} found` }, + ]; + + if (data.logDirectory) { + pairs.push({ key: 'Logs', value: data.logDirectory }); + } + + this.writeln(renderKeyValue(pairs)); + } + + /** + * Render file list + */ + fileList(files: string[], title = 'Files to execute'): void { + this.writeln(); + this.writeln(c.muted(`${symbols.arrowRight} ${title}:`)); + this.writeln(); + this.writeln(renderList(files)); + } + + /** + * Render ignored files + */ + ignoredFiles(files: string[]): void { + if (files.length === 0) return; + this.writeln(); + this.writeln(c.muted(`${symbols.info} Ignoring ${files.length} file${files.length > 1 ? 's' : ''}:`)); + for (const file of files) { + this.writeln(c.muted(` ${symbols.dot} ${file}`)); + } + } + + /** + * Render dry run notice + */ + dryRun(): void { + this.writeln(); + this.writeln( + renderBox( + [ + c.title('DRY RUN'), + c.muted('No changes will be made to the database'), + ], + { style: 'rounded', width: 46 } + ) + ); + } + + /** + * Render confirmation prompt + */ + confirmationWarning(): void { + this.writeln(); + this.writeln(c.warning(`${symbols.warning} This will execute SQL scripts on your database.`)); + this.writeln(c.muted(' Changes may modify or delete existing data.')); + this.writeln(c.muted(' Automatic rollback is enabled for failures.')); + this.writeln(); + } + + /** + * Render file execution start + */ + fileStart(fileName: string, index: number, total: number): void { + const progress = c.muted(`[${index + 1}/${total}]`); + this.writeln(`${progress} ${c.primary(symbols.running)} ${fileName}`); + } + + /** + * Render file execution result + */ + fileResult(result: FileResult): void { + // Move cursor up to overwrite the "running" line + if (process.stdout.isTTY) { + process.stdout.moveCursor?.(0, -1); + process.stdout.clearLine?.(0); + process.stdout.cursorTo?.(0); + } + + const duration = result.durationMs !== undefined ? `${result.durationMs}ms` : ''; + + if (result.success) { + this.writeln(` ${c.success(symbols.success)} ${result.fileName} ${c.muted(duration)}`); + } else { + this.writeln(` ${c.error(symbols.error)} ${result.fileName} ${c.error('failed')}`); + } + } + + /** + * Render file execution result without overwriting + */ + fileResultSimple(result: FileResult, index: number, total: number): void { + const progress = c.muted(`[${index + 1}/${total}]`); + const duration = result.durationMs !== undefined ? `${result.durationMs}ms` : ''; + + if (result.success) { + this.writeln(`${progress} ${c.success(symbols.success)} ${result.fileName} ${c.muted(duration)}`); + } else { + this.writeln(`${progress} ${c.error(symbols.error)} ${result.fileName} ${c.error('failed')}`); + } + } + + /** + * Render error details + */ + error(error: { + message: string; + code?: string; + detail?: string; + hint?: string; + fileName?: string; + }): void { + this.writeln(); + this.writeln(`${c.error(symbols.error)} ${c.error('Error:')} ${error.message}`); + + if (error.code) { + this.writeln(` ${c.label('Code:')} ${error.code}`); + } + if (error.detail) { + this.writeln(` ${c.label('Detail:')} ${error.detail}`); + } + if (error.hint) { + this.writeln(` ${c.label('Hint:')} ${c.info(error.hint)}`); + } + } + + /** + * Render execution summary + */ + summary(data: ExecutionSummaryData): void { + this.writeln(); + this.writeln(renderDivider(45)); + this.writeln(); + + // Stats line + const stats: string[] = []; + stats.push(c.success(`${symbols.success} ${data.successfulFiles}/${data.totalFiles} files`)); + stats.push(c.muted(`${data.totalDurationMs}ms`)); + + if (data.committed) { + stats.push(c.success('committed')); + } else { + stats.push(c.warning('not committed')); + } + + this.writeln(stats.join(c.muted(' • '))); + + // Status message + if (data.failedFiles === 0 && data.committed) { + this.writeln(); + this.writeln(c.success('All SQL scripts executed successfully!')); + } else if (data.failedFiles > 0) { + this.writeln(); + this.writeln(c.error('Transaction rolled back. No changes were made.')); + } else if (!data.committed) { + this.writeln(); + this.writeln(c.warning('Changes were NOT committed to database.')); + } + } + + /** + * Render info message + */ + info(message: string): void { + this.writeln(`${c.info(symbols.info)} ${message}`); + } + + /** + * Render success message + */ + success(message: string): void { + this.writeln(`${c.success(symbols.success)} ${message}`); + } + + /** + * Render warning message + */ + warning(message: string): void { + this.writeln(`${c.warning(symbols.warning)} ${message}`); + } + + /** + * Render error message + */ + errorMessage(message: string): void { + this.writeln(`${c.error(symbols.error)} ${message}`); + } + + /** + * Render SQL notice (from database) + */ + sqlNotice(message: string): void { + this.writeln(` ${c.muted('[SQL]')} ${c.muted(message)}`); + } + + /** + * Render cancelled operation + */ + cancelled(): void { + this.writeln(); + this.writeln(`${c.warning(symbols.warning)} Operation cancelled`); + } + + /** + * Render watch mode messages + */ + watchMode = { + started: (): void => { + this.writeln(); + this.writeln(`${c.primary(symbols.running)} ${c.primary('Watching for changes...')} ${c.muted('(Ctrl+C to stop)')}`); + }, + + fileChanged: (fileName: string): void => { + this.writeln(`${c.info(symbols.info)} Changed: ${fileName}`); + }, + + countdown: (seconds: number): void => { + this.write(`\r${c.muted(`${symbols.pending} Running in ${seconds}s... (save again to reset)`)} `); + }, + + executing: (): void => { + // Clear countdown line + if (process.stdout.isTTY) { + process.stdout.clearLine?.(0); + process.stdout.cursorTo?.(0); + } + this.writeln(`${c.primary(symbols.arrowRight)} Executing SQL files...`); + this.writeln(); + }, + + stopped: (): void => { + this.writeln(); + this.writeln(`${c.muted(symbols.info)} Stopped watching.`); + }, + + waiting: (): void => { + this.writeln(); + this.writeln(`${c.primary(symbols.running)} ${c.primary('Watching for changes...')} ${c.muted('(Ctrl+C to stop)')}`); + }, + }; + + /** + * Render empty line + */ + newline(): void { + this.writeln(); + } + + /** + * Render divider + */ + divider(width = 45): void { + this.writeln(renderDivider(width)); + } + + /** + * Create a spinner instance + */ + createSpinner(text?: string) { + return createSpinner({ text, stream: this.stream }); + } + + /** + * Clear the current line (for progress updates) + */ + clearLine(): void { + if (process.stdout.isTTY) { + process.stdout.clearLine?.(0); + process.stdout.cursorTo?.(0); + } + } +} + +/** + * Create a UI renderer instance + */ +export function createUIRenderer(options?: UIRendererOptions): UIRenderer { + return new UIRenderer(options); +} diff --git a/src/ui/theme.ts b/src/ui/theme.ts new file mode 100644 index 0000000..ae614ac --- /dev/null +++ b/src/ui/theme.ts @@ -0,0 +1,209 @@ +/** + * UI Theme - Colors, Symbols, and Styling Constants + * + * Centralized theme system following the Open/Closed principle. + * Modify theme values here without changing components. + */ + +/** + * ANSI escape codes for terminal colors + */ +export const colors = { + // Reset + reset: '\x1b[0m', + + // Modifiers + bold: '\x1b[1m', + dim: '\x1b[2m', + italic: '\x1b[3m', + underline: '\x1b[4m', + + // Foreground colors + black: '\x1b[30m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m', + gray: '\x1b[90m', + + // Bright foreground colors + brightRed: '\x1b[91m', + brightGreen: '\x1b[92m', + brightYellow: '\x1b[93m', + brightBlue: '\x1b[94m', + brightMagenta: '\x1b[95m', + brightCyan: '\x1b[96m', + brightWhite: '\x1b[97m', +} as const; + +/** + * Semantic color mappings for consistent styling + */ +export const theme = { + // Status colors + success: colors.green, + error: colors.red, + warning: colors.yellow, + info: colors.cyan, + muted: colors.gray, + + // UI elements + primary: colors.cyan, + secondary: colors.gray, + accent: colors.magenta, + highlight: colors.brightWhite, + + // Text styles + title: colors.bold + colors.white, + subtitle: colors.gray, + label: colors.gray, + value: colors.white, +} as const; + +/** + * Unicode symbols for visual indicators + */ +export const symbols = { + // Status indicators + success: '✓', + error: '✗', + warning: '⚠', + info: 'ℹ', + pending: '○', + running: '●', + + // Arrows and pointers + arrow: '→', + arrowRight: '▸', + arrowDown: '▾', + pointer: '❯', + + // Bullets + bullet: '•', + dot: '·', + + // Box drawing (rounded) + topLeft: '╭', + topRight: '╮', + bottomLeft: '╰', + bottomRight: '╯', + horizontal: '─', + vertical: '│', + + // Box drawing (sharp) + sharpTopLeft: '┌', + sharpTopRight: '┐', + sharpBottomLeft: '└', + sharpBottomRight: '┘', + sharpHorizontal: '─', + sharpVertical: '│', + sharpCross: '┼', + sharpTeeRight: '├', + sharpTeeLeft: '┤', + sharpTeeDown: '┬', + sharpTeeUp: '┴', + + // Misc + ellipsis: '…', + line: '─', + doubleLine: '═', +} as const; + +/** + * Spinner frames for animated progress + */ +export const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const; + +/** + * Alternative spinner styles + */ +export const spinnerStyles = { + dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], + line: ['|', '/', '-', '\\'], + circle: ['◐', '◓', '◑', '◒'], + bounce: ['⠁', '⠂', '⠄', '⠂'], +} as const; + +/** + * Detect if terminal supports colors + */ +export function supportsColor(): boolean { + // Check NO_COLOR env var (https://no-color.org/) + if (process.env.NO_COLOR !== undefined) { + return false; + } + + // Check FORCE_COLOR env var + if (process.env.FORCE_COLOR !== undefined) { + return true; + } + + // Check if stdout is a TTY + if (process.stdout.isTTY) { + return true; + } + + return false; +} + +/** + * Strip ANSI codes from a string + */ +export function stripAnsi(str: string): string { + // eslint-disable-next-line no-control-regex + return str.replace(/\x1b\[[0-9;]*m/g, ''); +} + +/** + * Get visible length of string (excluding ANSI codes) + */ +export function visibleLength(str: string): number { + return stripAnsi(str).length; +} + +/** + * Apply color to text (respects NO_COLOR) + */ +export function colorize(text: string, color: string): string { + if (!supportsColor()) { + return text; + } + return `${color}${text}${colors.reset}`; +} + +/** + * Shorthand color functions + */ +export const c = { + // Status + success: (text: string) => colorize(text, theme.success), + error: (text: string) => colorize(text, theme.error), + warning: (text: string) => colorize(text, theme.warning), + info: (text: string) => colorize(text, theme.info), + muted: (text: string) => colorize(text, theme.muted), + + // UI + primary: (text: string) => colorize(text, theme.primary), + secondary: (text: string) => colorize(text, theme.secondary), + accent: (text: string) => colorize(text, theme.accent), + highlight: (text: string) => colorize(text, theme.highlight), + + // Text + title: (text: string) => colorize(text, theme.title), + subtitle: (text: string) => colorize(text, theme.subtitle), + label: (text: string) => colorize(text, theme.label), + value: (text: string) => colorize(text, theme.value), + + // Raw colors + bold: (text: string) => colorize(text, colors.bold), + dim: (text: string) => colorize(text, colors.dim), + green: (text: string) => colorize(text, colors.green), + red: (text: string) => colorize(text, colors.red), + yellow: (text: string) => colorize(text, colors.yellow), + cyan: (text: string) => colorize(text, colors.cyan), + gray: (text: string) => colorize(text, colors.gray), + white: (text: string) => colorize(text, colors.white), +} as const; diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..226e833 --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,797 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + ArgParser, + parseArgs, + EnvLoader, + parseEnvContent, + loadEnvFile, + CliValidator, + DatabaseUrlValidator, + SqlDirectoryValidator, + EnvFileValidator, + HelpDisplay, + showHelp, + showVersion, + getHelpText, + buildRunnerConfig, + buildRunOptions, + CLI_DEFAULTS, +} from '../src/cli/index.js'; +import type { CliArgs, FileSystem, ProcessEnv, CliOutput } from '../src/cli/index.js'; + +// ============================================================================= +// Argument Parser Tests +// ============================================================================= + +describe('ArgParser', () => { + let parser: ArgParser; + + beforeEach(() => { + parser = new ArgParser(); + }); + + describe('help and version', () => { + it('should parse -h flag', () => { + const result = parser.parse(['-h']); + expect(result.help).toBe(true); + }); + + it('should parse --help flag', () => { + const result = parser.parse(['--help']); + expect(result.help).toBe(true); + }); + + it('should parse -v flag', () => { + const result = parser.parse(['-v']); + expect(result.version).toBe(true); + }); + + it('should parse --version flag', () => { + const result = parser.parse(['--version']); + expect(result.version).toBe(true); + }); + }); + + describe('directory options', () => { + it('should parse -d option', () => { + const result = parser.parse(['-d', './custom/dir']); + expect(result.sqlDirectory).toBe('./custom/dir'); + }); + + it('should parse --directory option', () => { + const result = parser.parse(['--directory', './custom/dir']); + expect(result.sqlDirectory).toBe('./custom/dir'); + }); + + it('should parse --sql-directory option', () => { + const result = parser.parse(['--sql-directory', './custom/dir']); + expect(result.sqlDirectory).toBe('./custom/dir'); + }); + + it('should parse positional directory argument', () => { + const result = parser.parse(['./my/sql/files']); + expect(result.sqlDirectory).toBe('./my/sql/files'); + }); + + it('should use default directory when not specified', () => { + const result = parser.parse([]); + expect(result.sqlDirectory).toBe('./sql'); + }); + }); + + describe('database URL options', () => { + it('should parse -u option', () => { + const result = parser.parse(['-u', 'postgres://localhost/db']); + expect(result.databaseUrl).toBe('postgres://localhost/db'); + }); + + it('should parse --url option', () => { + const result = parser.parse(['--url', 'postgres://localhost/db']); + expect(result.databaseUrl).toBe('postgres://localhost/db'); + }); + + it('should parse --database-url option', () => { + const result = parser.parse(['--database-url', 'postgres://localhost/db']); + expect(result.databaseUrl).toBe('postgres://localhost/db'); + }); + }); + + describe('environment file options', () => { + it('should parse -e option', () => { + const result = parser.parse(['-e', '.env.local']); + expect(result.envFile).toBe('.env.local'); + }); + + it('should parse --env option', () => { + const result = parser.parse(['--env', '.env.production']); + expect(result.envFile).toBe('.env.production'); + }); + + it('should parse --env-file option', () => { + const result = parser.parse(['--env-file', './config/.env']); + expect(result.envFile).toBe('./config/.env'); + }); + }); + + describe('confirmation options', () => { + it('should parse -y flag', () => { + const result = parser.parse(['-y']); + expect(result.skipConfirmation).toBe(true); + }); + + it('should parse --yes flag', () => { + const result = parser.parse(['--yes']); + expect(result.skipConfirmation).toBe(true); + }); + + it('should parse --skip-confirmation flag', () => { + const result = parser.parse(['--skip-confirmation']); + expect(result.skipConfirmation).toBe(true); + }); + + it('should parse --confirmation-phrase option', () => { + const result = parser.parse(['--confirmation-phrase', 'EXECUTE']); + expect(result.confirmationPhrase).toBe('EXECUTE'); + }); + + it('should use default confirmation phrase', () => { + const result = parser.parse([]); + expect(result.confirmationPhrase).toBe('CONFIRM'); + }); + }); + + describe('logging options', () => { + it('should parse --verbose flag', () => { + const result = parser.parse(['--verbose']); + expect(result.verbose).toBe(true); + }); + + it('should parse --no-logs flag', () => { + const result = parser.parse(['--no-logs']); + expect(result.noLogs).toBe(true); + }); + + it('should parse --log-directory option', () => { + const result = parser.parse(['--log-directory', './my-logs']); + expect(result.logDirectory).toBe('./my-logs'); + }); + + it('should use default log directory', () => { + const result = parser.parse([]); + expect(result.logDirectory).toBe('./logs'); + }); + }); + + describe('execution options', () => { + it('should parse --dry-run flag', () => { + const result = parser.parse(['--dry-run']); + expect(result.dryRun).toBe(true); + }); + + it('should parse --watch flag', () => { + const result = parser.parse(['--watch']); + expect(result.watch).toBe(true); + }); + + it('should parse -w flag', () => { + const result = parser.parse(['-w']); + expect(result.watch).toBe(true); + }); + + it('should parse --only option with single file', () => { + const result = parser.parse(['--only', 'test.sql']); + expect(result.onlyFiles).toEqual(['test.sql']); + }); + + it('should parse --only option with multiple files', () => { + const result = parser.parse(['--only', '01_first.sql,02_second.sql,03_third.sql']); + expect(result.onlyFiles).toEqual(['01_first.sql', '02_second.sql', '03_third.sql']); + }); + + it('should parse --skip option with single file', () => { + const result = parser.parse(['--skip', 'seed.sql']); + expect(result.skipFiles).toEqual(['seed.sql']); + }); + + it('should parse --skip option with multiple files', () => { + const result = parser.parse(['--skip', 'seed.sql, test.sql']); + expect(result.skipFiles).toEqual(['seed.sql', 'test.sql']); + }); + }); + + describe('combined options', () => { + it('should parse multiple options together', () => { + const result = parser.parse([ + '-d', './migrations', + '-u', 'postgres://localhost/db', + '-y', + '--verbose', + '--dry-run', + ]); + + expect(result.sqlDirectory).toBe('./migrations'); + expect(result.databaseUrl).toBe('postgres://localhost/db'); + expect(result.skipConfirmation).toBe(true); + expect(result.verbose).toBe(true); + expect(result.dryRun).toBe(true); + }); + + it('should handle all flags', () => { + const result = parser.parse([ + '-y', + '--verbose', + '--dry-run', + '--no-logs', + '-w', + ]); + + expect(result.skipConfirmation).toBe(true); + expect(result.verbose).toBe(true); + expect(result.dryRun).toBe(true); + expect(result.noLogs).toBe(true); + expect(result.watch).toBe(true); + }); + }); + + describe('parseArgs convenience function', () => { + it('should work the same as ArgParser.parse', () => { + const result = parseArgs(['-y', '--verbose', './sql']); + expect(result.skipConfirmation).toBe(true); + expect(result.verbose).toBe(true); + expect(result.sqlDirectory).toBe('./sql'); + }); + }); + + describe('getSupportedFlags', () => { + it('should return all supported flags', () => { + const flags = parser.getSupportedFlags(); + expect(flags).toContain('-h'); + expect(flags).toContain('--help'); + expect(flags).toContain('-y'); + expect(flags).toContain('--yes'); + expect(flags).toContain('-d'); + expect(flags).toContain('--directory'); + }); + }); +}); + +// ============================================================================= +// Environment Loader Tests +// ============================================================================= + +describe('EnvLoader', () => { + let testDir: string; + let mockEnv: Map; + let mockProcessEnv: ProcessEnv; + let mockFileSystem: FileSystem; + let fileContents: Map; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-env-test-')); + mockEnv = new Map(); + fileContents = new Map(); + + mockProcessEnv = { + get: (key) => mockEnv.get(key), + set: (key, value) => mockEnv.set(key, value), + }; + + mockFileSystem = { + exists: (p) => fileContents.has(p), + readFile: (p) => { + const content = fileContents.get(p); + if (!content) throw new Error('File not found'); + return content; + }, + }; + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + describe('parseEnvContent', () => { + it('should parse simple key=value pairs', () => { + const result = parseEnvContent('KEY=value'); + expect(result.get('KEY')).toBe('value'); + }); + + it('should skip comments', () => { + const result = parseEnvContent('# comment\nKEY=value'); + expect(result.size).toBe(1); + expect(result.get('KEY')).toBe('value'); + }); + + it('should skip empty lines', () => { + const result = parseEnvContent('\n\nKEY=value\n\n'); + expect(result.size).toBe(1); + }); + + it('should remove double quotes', () => { + const result = parseEnvContent('KEY="quoted value"'); + expect(result.get('KEY')).toBe('quoted value'); + }); + + it('should remove single quotes', () => { + const result = parseEnvContent("KEY='single quoted'"); + expect(result.get('KEY')).toBe('single quoted'); + }); + + it('should handle values with equals signs', () => { + const result = parseEnvContent('KEY=value=with=equals'); + expect(result.get('KEY')).toBe('value=with=equals'); + }); + + it('should handle multiple entries', () => { + const result = parseEnvContent('KEY1=value1\nKEY2=value2\nKEY3=value3'); + expect(result.size).toBe(3); + expect(result.get('KEY1')).toBe('value1'); + expect(result.get('KEY2')).toBe('value2'); + expect(result.get('KEY3')).toBe('value3'); + }); + }); + + describe('EnvLoader class', () => { + it('should load environment from file', () => { + fileContents.set('/test/.env', 'TEST_VAR=test_value'); + const loader = new EnvLoader(mockFileSystem, mockProcessEnv); + + const result = loader.load('/test/.env'); + + expect(result.success).toBe(true); + expect(result.loadedKeys).toContain('TEST_VAR'); + expect(mockEnv.get('TEST_VAR')).toBe('test_value'); + }); + + it('should not override existing env vars', () => { + mockEnv.set('TEST_VAR', 'original'); + fileContents.set('/test/.env', 'TEST_VAR=new_value'); + const loader = new EnvLoader(mockFileSystem, mockProcessEnv); + + const result = loader.load('/test/.env'); + + expect(result.success).toBe(true); + expect(mockEnv.get('TEST_VAR')).toBe('original'); + }); + + it('should return error for non-existent file', () => { + const loader = new EnvLoader(mockFileSystem, mockProcessEnv); + + const result = loader.load('/nonexistent'); + + expect(result.success).toBe(false); + expect(result.error).toContain('not found'); + }); + + it('should check if file exists', () => { + fileContents.set('/test/.env', ''); + const loader = new EnvLoader(mockFileSystem, mockProcessEnv); + + expect(loader.exists('/test/.env')).toBe(true); + expect(loader.exists('/nonexistent')).toBe(false); + }); + + it('should get environment variable', () => { + mockEnv.set('MY_VAR', 'my_value'); + const loader = new EnvLoader(mockFileSystem, mockProcessEnv); + + expect(loader.get('MY_VAR')).toBe('my_value'); + expect(loader.get('NONEXISTENT')).toBeUndefined(); + }); + }); + + describe('loadEnvFile with real file system', () => { + it('should load from actual file', () => { + const envFile = path.join(testDir, '.env'); + fs.writeFileSync(envFile, 'TEST_KEY=test_value'); + + const result = loadEnvFile(envFile, undefined, mockProcessEnv); + + expect(result.success).toBe(true); + expect(mockEnv.get('TEST_KEY')).toBe('test_value'); + }); + }); +}); + +// ============================================================================= +// Validators Tests +// ============================================================================= + +describe('Validators', () => { + describe('DatabaseUrlValidator', () => { + const validator = new DatabaseUrlValidator(); + + it('should accept valid postgres URL', () => { + const result = validator.validate('postgres://user:pass@localhost/db'); + expect(result.valid).toBe(true); + }); + + it('should accept valid postgresql URL', () => { + const result = validator.validate('postgresql://user:pass@localhost/db'); + expect(result.valid).toBe(true); + }); + + it('should reject undefined', () => { + const result = validator.validate(undefined); + expect(result.valid).toBe(false); + expect(result.error).toContain('DATABASE_URL is required'); + }); + + it('should reject empty string', () => { + const result = validator.validate(''); + expect(result.valid).toBe(false); + }); + + it('should reject invalid protocol', () => { + const result = validator.validate('mysql://user:pass@localhost/db'); + expect(result.valid).toBe(false); + expect(result.error).toContain('postgres://'); + }); + }); + + describe('SqlDirectoryValidator', () => { + let testDir: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sql-dir-test-')); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should accept existing directory', () => { + const validator = new SqlDirectoryValidator(); + const result = validator.validate(testDir); + expect(result.valid).toBe(true); + }); + + it('should reject non-existent directory', () => { + const validator = new SqlDirectoryValidator(); + const result = validator.validate('/nonexistent/path/to/sql'); + expect(result.valid).toBe(false); + expect(result.error).toContain('not found'); + }); + + it('should reject file path', () => { + const filePath = path.join(testDir, 'file.txt'); + fs.writeFileSync(filePath, 'content'); + + const validator = new SqlDirectoryValidator(); + const result = validator.validate(filePath); + expect(result.valid).toBe(false); + expect(result.error).toContain('not a directory'); + }); + + it('should resolve path', () => { + const validator = new SqlDirectoryValidator(); + const resolved = validator.resolve('./sql'); + expect(path.isAbsolute(resolved)).toBe(true); + }); + }); + + describe('EnvFileValidator', () => { + let testDir: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'env-file-test-')); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should accept undefined (optional)', () => { + const validator = new EnvFileValidator(); + const result = validator.validate(undefined); + expect(result.valid).toBe(true); + }); + + it('should accept existing file', () => { + const envFile = path.join(testDir, '.env'); + fs.writeFileSync(envFile, 'KEY=value'); + + const validator = new EnvFileValidator(); + const result = validator.validate(envFile); + expect(result.valid).toBe(true); + }); + + it('should reject non-existent file', () => { + const validator = new EnvFileValidator(); + const result = validator.validate('/nonexistent/.env'); + expect(result.valid).toBe(false); + expect(result.error).toContain('not found'); + }); + }); + + describe('CliValidator', () => { + let testDir: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-validator-test-')); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should validate all inputs', () => { + const validator = new CliValidator(); + + // Database URL + expect(validator.validateDatabaseUrl('postgres://localhost/db').valid).toBe(true); + expect(validator.validateDatabaseUrl(undefined).valid).toBe(false); + + // SQL directory + expect(validator.validateSqlDirectory(testDir).valid).toBe(true); + expect(validator.validateSqlDirectory('/nonexistent').valid).toBe(false); + + // Env file + expect(validator.validateEnvFile(undefined).valid).toBe(true); + }); + + describe('argument combinations', () => { + it('should reject --watch with --dry-run', () => { + const validator = new CliValidator(); + const result = validator.validateArgumentCombinations({ + watch: true, + dryRun: true, + }); + + expect(result.valid).toBe(false); + expect(result.error).toContain('--watch and --dry-run cannot be used together'); + }); + + it('should warn when --only and --skip are both specified', () => { + const validator = new CliValidator(); + const result = validator.validateArgumentCombinations({ + onlyFiles: ['file1.sql'], + skipFiles: ['file2.sql'], + }); + + expect(result.valid).toBe(true); + expect(result.warnings).toBeDefined(); + expect(result.warnings).toHaveLength(1); + expect(result.warnings![0]).toContain('--only and --skip'); + }); + + it('should warn about watch mode with confirmation prompt', () => { + const validator = new CliValidator(); + const result = validator.validateArgumentCombinations({ + watch: true, + skipConfirmation: false, + }); + + expect(result.valid).toBe(true); + expect(result.warnings).toBeDefined(); + expect(result.warnings![0]).toContain('confirmation'); + }); + + it('should not warn about watch mode when -y is specified', () => { + const validator = new CliValidator(); + const result = validator.validateArgumentCombinations({ + watch: true, + skipConfirmation: true, + }); + + expect(result.valid).toBe(true); + expect(result.warnings).toBeUndefined(); + }); + + it('should return no warnings for valid combinations', () => { + const validator = new CliValidator(); + const result = validator.validateArgumentCombinations({ + verbose: true, + skipConfirmation: true, + }); + + expect(result.valid).toBe(true); + expect(result.warnings).toBeUndefined(); + }); + }); + }); +}); + +// ============================================================================= +// Help Display Tests +// ============================================================================= + +describe('HelpDisplay', () => { + describe('showHelp', () => { + it('should output help text', () => { + const logs: string[] = []; + const mockOutput: CliOutput = { + log: (msg) => logs.push(msg), + error: () => {}, + warn: () => {}, + }; + + const display = new HelpDisplay(mockOutput); + display.showHelp(); + + expect(logs.length).toBeGreaterThan(0); + expect(logs[0]).toContain('Supabase SQL Dev Runner'); + }); + }); + + describe('showVersion', () => { + it('should output version', () => { + const logs: string[] = []; + const mockOutput: CliOutput = { + log: (msg) => logs.push(msg), + error: () => {}, + warn: () => {}, + }; + + const mockFs: FileSystem = { + exists: () => false, + readFile: () => '', + }; + + const display = new HelpDisplay(mockOutput, mockFs); + display.showVersion(); + + expect(logs.length).toBe(1); + // New format uses styled "sql-runner" name with version + expect(logs[0]).toContain('sql-runner'); + expect(logs[0]).toMatch(/v\d+\.\d+\.\d+/); + }); + + it('should read version from package.json if available', () => { + const logs: string[] = []; + const mockOutput: CliOutput = { + log: (msg) => logs.push(msg), + error: () => {}, + warn: () => {}, + }; + + const mockFs: FileSystem = { + exists: (p) => p.endsWith('package.json'), + readFile: () => JSON.stringify({ name: 'test-pkg', version: '2.5.0' }), + }; + + const display = new HelpDisplay(mockOutput, mockFs); + display.showVersion(); + + expect(logs[0]).toContain('v2.5.0'); + }); + }); + + describe('getHelpText', () => { + it('should return help text string', () => { + const text = getHelpText(); + // New format uses styled section headers without colons + expect(text).toContain('Usage'); + expect(text).toContain('Options'); + expect(text).toContain('Examples'); + }); + }); + + describe('convenience functions', () => { + it('showHelp should work', () => { + const logs: string[] = []; + const mockOutput: CliOutput = { + log: (msg) => logs.push(msg), + error: () => {}, + warn: () => {}, + }; + + showHelp(mockOutput); + expect(logs.length).toBeGreaterThan(0); + }); + + it('showVersion should work', () => { + const logs: string[] = []; + const mockOutput: CliOutput = { + log: (msg) => logs.push(msg), + error: () => {}, + warn: () => {}, + }; + + showVersion(mockOutput); + expect(logs.length).toBe(1); + }); + }); +}); + +// ============================================================================= +// Executor Config Builder Tests +// ============================================================================= + +describe('Executor Config Builders', () => { + describe('buildRunnerConfig', () => { + it('should build runner config from executor config', () => { + const args: CliArgs = { + ...CLI_DEFAULTS, + skipConfirmation: true, + verbose: true, + noLogs: false, + logDirectory: './custom-logs', + confirmationPhrase: 'GO', + }; + + const config = buildRunnerConfig({ + databaseUrl: 'postgres://localhost/db', + sqlDirectory: '/path/to/sql', + args, + }); + + expect(config.databaseUrl).toBe('postgres://localhost/db'); + expect(config.sqlDirectory).toBe('/path/to/sql'); + expect(config.requireConfirmation).toBe(false); + expect(config.confirmationPhrase).toBe('GO'); + expect(config.verbose).toBe(true); + expect(config.logDirectory).toBe('./custom-logs'); + }); + + it('should set logDirectory to null when noLogs is true', () => { + const args: CliArgs = { + ...CLI_DEFAULTS, + noLogs: true, + }; + + const config = buildRunnerConfig({ + databaseUrl: 'postgres://localhost/db', + sqlDirectory: '/path/to/sql', + args, + }); + + expect(config.logDirectory).toBeNull(); + }); + }); + + describe('buildRunOptions', () => { + it('should build run options from args', () => { + const args: CliArgs = { + ...CLI_DEFAULTS, + skipConfirmation: true, + dryRun: true, + onlyFiles: ['01.sql', '02.sql'], + skipFiles: ['seed.sql'], + }; + + const options = buildRunOptions(args); + + expect(options.skipConfirmation).toBe(true); + expect(options.dryRun).toBe(true); + expect(options.onlyFiles).toEqual(['01.sql', '02.sql']); + expect(options.skipFiles).toEqual(['seed.sql']); + }); + + it('should allow overriding skipConfirmation', () => { + const args: CliArgs = { + ...CLI_DEFAULTS, + skipConfirmation: false, + }; + + const options = buildRunOptions(args, true); + expect(options.skipConfirmation).toBe(true); + }); + }); +}); + +// ============================================================================= +// CLI Defaults Tests +// ============================================================================= + +describe('CLI_DEFAULTS', () => { + it('should have correct default values', () => { + expect(CLI_DEFAULTS.sqlDirectory).toBe('./sql'); + expect(CLI_DEFAULTS.skipConfirmation).toBe(false); + expect(CLI_DEFAULTS.confirmationPhrase).toBe('CONFIRM'); + expect(CLI_DEFAULTS.verbose).toBe(false); + expect(CLI_DEFAULTS.dryRun).toBe(false); + expect(CLI_DEFAULTS.noLogs).toBe(false); + expect(CLI_DEFAULTS.logDirectory).toBe('./logs'); + expect(CLI_DEFAULTS.watch).toBe(false); + expect(CLI_DEFAULTS.help).toBe(false); + expect(CLI_DEFAULTS.version).toBe(false); + }); + + it('should be frozen (immutable)', () => { + expect(Object.isFrozen(CLI_DEFAULTS)).toBe(true); + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..5fadbcd --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,420 @@ +/** + * Configuration Loading Tests + * + * Tests for config file loading, merging, and schema validation. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { ConfigLoader } from '../src/cli/config-loader.js'; +import { ConfigMerger } from '../src/cli/config-merger.js'; +import { CONFIG_MODULE_NAME, type ConfigFileSchema } from '../src/cli/config-schema.js'; +import type { CliArgs } from '../src/cli/types.js'; +import { CLI_DEFAULTS } from '../src/cli/types.js'; + +describe('ConfigLoader', () => { + let testDir: string; + let loader: ConfigLoader; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'config-test-')); + loader = new ConfigLoader(); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + loader.clearCache(); + }); + + describe('load()', () => { + it('should return empty config when no config file exists', async () => { + const result = await loader.load(testDir); + + expect(result.found).toBe(false); + expect(result.config).toEqual({}); + expect(result.filepath).toBeUndefined(); + }); + + it('should load .sql-runnerrc.json config file', async () => { + const config: ConfigFileSchema = { + directory: './custom-sql', + verbose: true, + skip: ['seed.sql'], + }; + writeFileSync(join(testDir, '.sql-runnerrc.json'), JSON.stringify(config)); + + const result = await loader.load(testDir); + + expect(result.found).toBe(true); + expect(result.config.directory).toBe('./custom-sql'); + expect(result.config.verbose).toBe(true); + expect(result.config.skip).toEqual(['seed.sql']); + expect(result.filepath).toContain('.sql-runnerrc.json'); + }); + + it('should load sql-runner.config.mjs config file', async () => { + const configContent = `export default { + directory: './migrations', + yes: true, + logDirectory: './custom-logs' + };`; + writeFileSync(join(testDir, 'sql-runner.config.mjs'), configContent); + + const result = await loader.load(testDir); + + expect(result.found).toBe(true); + expect(result.config.directory).toBe('./migrations'); + expect(result.config.yes).toBe(true); + expect(result.config.logDirectory).toBe('./custom-logs'); + }); + + it('should load config from package.json sql-runner field', async () => { + const packageJson = { + name: 'test-project', + [CONFIG_MODULE_NAME]: { + directory: './db-scripts', + ssl: false, + }, + }; + writeFileSync(join(testDir, 'package.json'), JSON.stringify(packageJson, null, 2)); + + const result = await loader.load(testDir); + + expect(result.found).toBe(true); + expect(result.config.directory).toBe('./db-scripts'); + expect(result.config.ssl).toBe(false); + }); + + it('should cache loaded config', async () => { + const config = { directory: './cached' }; + writeFileSync(join(testDir, '.sql-runnerrc.json'), JSON.stringify(config)); + + const result1 = await loader.load(testDir); + const result2 = await loader.load(testDir); + + expect(result1).toBe(result2); // Same reference (cached) + }); + + it('should clear cache when requested', async () => { + const config1 = { directory: './first' }; + writeFileSync(join(testDir, '.sql-runnerrc.json'), JSON.stringify(config1)); + + const result1 = await loader.load(testDir); + loader.clearCache(); + + const config2 = { directory: './second' }; + writeFileSync(join(testDir, '.sql-runnerrc.json'), JSON.stringify(config2)); + + const result2 = await loader.load(testDir); + + expect(result1.config.directory).toBe('./first'); + expect(result2.config.directory).toBe('./second'); + }); + }); + + describe('loadFromPath()', () => { + it('should load config from specific file path', async () => { + const configPath = join(testDir, 'custom-config.json'); + const config = { directory: './specific-path', verbose: true }; + writeFileSync(configPath, JSON.stringify(config)); + + const result = await loader.loadFromPath(configPath); + + expect(result.found).toBe(true); + expect(result.config.directory).toBe('./specific-path'); + expect(result.config.verbose).toBe(true); + }); + + it('should throw error for non-existent file', async () => { + const configPath = join(testDir, 'nonexistent.json'); + + await expect(loader.loadFromPath(configPath)).rejects.toThrow(); + }); + }); + + describe('validation and normalization', () => { + it('should normalize string properties', async () => { + const config = { + directory: './sql', + databaseUrl: 'postgres://localhost/db', + envFile: '.env.local', + confirmationPhrase: 'YES', + logDirectory: './logs', + filePattern: '\\.sql$', + ignorePattern: '^_', + }; + writeFileSync(join(testDir, '.sql-runnerrc.json'), JSON.stringify(config)); + + const result = await loader.load(testDir); + + expect(result.config.directory).toBe('./sql'); + expect(result.config.databaseUrl).toBe('postgres://localhost/db'); + expect(result.config.envFile).toBe('.env.local'); + expect(result.config.confirmationPhrase).toBe('YES'); + expect(result.config.logDirectory).toBe('./logs'); + expect(result.config.filePattern).toBe('\\.sql$'); + expect(result.config.ignorePattern).toBe('^_'); + }); + + it('should normalize boolean properties', async () => { + const config = { + yes: true, + verbose: false, + dryRun: true, + noLogs: false, + watch: true, + ssl: false, + }; + writeFileSync(join(testDir, '.sql-runnerrc.json'), JSON.stringify(config)); + + const result = await loader.load(testDir); + + expect(result.config.yes).toBe(true); + expect(result.config.verbose).toBe(false); + expect(result.config.dryRun).toBe(true); + expect(result.config.noLogs).toBe(false); + expect(result.config.watch).toBe(true); + expect(result.config.ssl).toBe(false); + }); + + it('should normalize array properties', async () => { + const config = { + only: ['01_setup.sql', '02_data.sql'], + skip: ['seed.sql'], + }; + writeFileSync(join(testDir, '.sql-runnerrc.json'), JSON.stringify(config)); + + const result = await loader.load(testDir); + + expect(result.config.only).toEqual(['01_setup.sql', '02_data.sql']); + expect(result.config.skip).toEqual(['seed.sql']); + }); + + it('should filter out non-string values from arrays', async () => { + const config = { + only: ['valid.sql', 123, null, 'another.sql'], + skip: [true, 'skip.sql', {}], + }; + writeFileSync(join(testDir, '.sql-runnerrc.json'), JSON.stringify(config)); + + const result = await loader.load(testDir); + + expect(result.config.only).toEqual(['valid.sql', 'another.sql']); + expect(result.config.skip).toEqual(['skip.sql']); + }); + + it('should ignore invalid property types', async () => { + const config = { + directory: 123, // Should be string + verbose: 'yes', // Should be boolean + only: 'single.sql', // Should be array + }; + writeFileSync(join(testDir, '.sql-runnerrc.json'), JSON.stringify(config)); + + const result = await loader.load(testDir); + + expect(result.config.directory).toBeUndefined(); + expect(result.config.verbose).toBeUndefined(); + expect(result.config.only).toBeUndefined(); + }); + }); +}); + +describe('ConfigMerger', () => { + let merger: ConfigMerger; + + beforeEach(() => { + merger = new ConfigMerger(); + }); + + describe('merge()', () => { + it('should use defaults when no config file and default CLI args', () => { + const cliArgs: CliArgs = { ...CLI_DEFAULTS }; + const fileConfig: ConfigFileSchema = {}; + + const result = merger.merge(cliArgs, fileConfig); + + expect(result.sqlDirectory).toBe(CLI_DEFAULTS.sqlDirectory); + expect(result.skipConfirmation).toBe(CLI_DEFAULTS.skipConfirmation); + expect(result.verbose).toBe(CLI_DEFAULTS.verbose); + expect(result.configFileUsed).toBe(false); + }); + + it('should apply config file values over defaults', () => { + const cliArgs: CliArgs = { ...CLI_DEFAULTS }; + const fileConfig: ConfigFileSchema = { + directory: './custom-sql', + verbose: true, + yes: true, + }; + + const result = merger.merge(cliArgs, fileConfig, '/path/to/config'); + + expect(result.sqlDirectory).toBe('./custom-sql'); + expect(result.verbose).toBe(true); + expect(result.skipConfirmation).toBe(true); + expect(result.configFileUsed).toBe(true); + expect(result.configFilePath).toBe('/path/to/config'); + }); + + it('should apply CLI args over config file values', () => { + const cliArgs: CliArgs = { + ...CLI_DEFAULTS, + sqlDirectory: './cli-sql', + verbose: true, + }; + const fileConfig: ConfigFileSchema = { + directory: './config-sql', + verbose: false, + yes: true, + }; + + const result = merger.merge(cliArgs, fileConfig); + + expect(result.sqlDirectory).toBe('./cli-sql'); // CLI wins + expect(result.verbose).toBe(true); // CLI wins + expect(result.skipConfirmation).toBe(true); // From config (not overridden by CLI) + }); + + it('should map config file properties to CLI args correctly', () => { + const cliArgs: CliArgs = { ...CLI_DEFAULTS }; + const fileConfig: ConfigFileSchema = { + directory: './sql', + databaseUrl: 'postgres://localhost/db', + envFile: '.env.local', + yes: true, + confirmationPhrase: 'DO IT', + verbose: true, + dryRun: true, + noLogs: true, + logDirectory: './custom-logs', + only: ['01.sql'], + skip: ['seed.sql'], + watch: true, + ssl: false, + filePattern: '\\.psql$', + ignorePattern: '^skip_', + }; + + const result = merger.merge(cliArgs, fileConfig); + + expect(result.sqlDirectory).toBe('./sql'); + expect(result.databaseUrl).toBe('postgres://localhost/db'); + expect(result.envFile).toBe('.env.local'); + expect(result.skipConfirmation).toBe(true); + expect(result.confirmationPhrase).toBe('DO IT'); + expect(result.verbose).toBe(true); + expect(result.dryRun).toBe(true); + expect(result.noLogs).toBe(true); + expect(result.logDirectory).toBe('./custom-logs'); + expect(result.onlyFiles).toEqual(['01.sql']); + expect(result.skipFiles).toEqual(['seed.sql']); + expect(result.watch).toBe(true); + expect(result.ssl).toBe(false); + expect(result.filePattern).toBe('\\.psql$'); + expect(result.ignorePattern).toBe('^skip_'); + }); + + it('should preserve help and version flags from CLI', () => { + const cliArgs: CliArgs = { + ...CLI_DEFAULTS, + help: true, + version: true, + }; + const fileConfig: ConfigFileSchema = {}; + + const result = merger.merge(cliArgs, fileConfig); + + expect(result.help).toBe(true); + expect(result.version).toBe(true); + }); + + it('should only override CLI values that differ from defaults', () => { + const cliArgs: CliArgs = { + ...CLI_DEFAULTS, + // Only sqlDirectory is explicitly set (differs from default) + sqlDirectory: './explicit-cli', + }; + const fileConfig: ConfigFileSchema = { + directory: './from-config', + logDirectory: './config-logs', + }; + + const result = merger.merge(cliArgs, fileConfig); + + // CLI arg was explicitly set, so it wins + expect(result.sqlDirectory).toBe('./explicit-cli'); + // Config file value used since CLI didn't override + expect(result.logDirectory).toBe('./config-logs'); + }); + }); +}); + +describe('Config file formats', () => { + let testDir: string; + let loader: ConfigLoader; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'config-format-test-')); + loader = new ConfigLoader(); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + loader.clearCache(); + }); + + it('should load .sql-runnerrc (JSON format)', async () => { + const config = { directory: './json-rc' }; + writeFileSync(join(testDir, '.sql-runnerrc'), JSON.stringify(config)); + + const result = await loader.load(testDir); + + expect(result.found).toBe(true); + expect(result.config.directory).toBe('./json-rc'); + }); + + it('should load .sql-runnerrc.yaml', async () => { + const yamlContent = `directory: ./yaml-config +verbose: true +skip: + - seed.sql + - test.sql`; + writeFileSync(join(testDir, '.sql-runnerrc.yaml'), yamlContent); + + const result = await loader.load(testDir); + + expect(result.found).toBe(true); + expect(result.config.directory).toBe('./yaml-config'); + expect(result.config.verbose).toBe(true); + expect(result.config.skip).toEqual(['seed.sql', 'test.sql']); + }); + + it('should load .sql-runnerrc.yml', async () => { + const yamlContent = `directory: ./yml-config +yes: true`; + writeFileSync(join(testDir, '.sql-runnerrc.yml'), yamlContent); + + const result = await loader.load(testDir); + + expect(result.found).toBe(true); + expect(result.config.directory).toBe('./yml-config'); + expect(result.config.yes).toBe(true); + }); + + it('should load sql-runner.config.cjs', async () => { + const cjsContent = `module.exports = { + directory: './cjs-config', + verbose: true + };`; + writeFileSync(join(testDir, 'sql-runner.config.cjs'), cjsContent); + + const result = await loader.load(testDir); + + expect(result.found).toBe(true); + expect(result.config.directory).toBe('./cjs-config'); + expect(result.config.verbose).toBe(true); + }); +}); diff --git a/tests/connection.test.ts b/tests/connection.test.ts new file mode 100644 index 0000000..fecc865 --- /dev/null +++ b/tests/connection.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect } from 'vitest'; +import { + parseDatabaseUrl, + maskPassword, + validateDatabaseUrl, + getConnectionErrorHelp, + formatConnectionErrorHelp, +} from '../src/core/connection.js'; + +describe('parseDatabaseUrl', () => { + it('should parse a valid Supabase URL', () => { + const url = 'postgres://postgres.abc123:mypassword@aws-0-us-east-1.pooler.supabase.com:5432/postgres'; + const config = parseDatabaseUrl(url); + + expect(config.host).toBe('aws-0-us-east-1.pooler.supabase.com'); + expect(config.port).toBe(5432); + expect(config.database).toBe('postgres'); + expect(config.user).toBe('postgres.abc123'); + expect(config.password).toBe('mypassword'); + expect(config.ssl).toEqual({ rejectUnauthorized: false }); + }); + + it('should parse URL with default port', () => { + const url = 'postgres://user:pass@localhost/mydb'; + const config = parseDatabaseUrl(url); + + expect(config.port).toBe(5432); + }); + + it('should parse URL with special characters in password', () => { + const url = 'postgres://user:p%40ss%3Dword@localhost:5432/db'; + const config = parseDatabaseUrl(url); + + expect(config.password).toBe('p@ss=word'); + }); + + it('should handle custom SSL option', () => { + const url = 'postgres://user:pass@localhost/db'; + const config = parseDatabaseUrl(url, false); + + expect(config.ssl).toBe(false); + }); + + it('should throw on invalid URL', () => { + expect(() => parseDatabaseUrl('not-a-url')).toThrow('Invalid database URL format'); + }); + + it('should throw on missing hostname', () => { + expect(() => parseDatabaseUrl('postgres://:pass@/db')).toThrow(); + }); +}); + +describe('maskPassword', () => { + it('should mask password in URL', () => { + const url = 'postgres://user:secretpassword@localhost:5432/db'; + const masked = maskPassword(url); + + expect(masked).toBe('postgres://user:***@localhost:5432/db'); + }); + + it('should handle URL without password', () => { + const url = 'postgres://user@localhost:5432/db'; + const masked = maskPassword(url); + + expect(masked).toBe('postgres://user@localhost:5432/db'); + }); + + it('should handle invalid URL gracefully', () => { + const url = 'invalid://user:pass@somewhere'; + const masked = maskPassword(url); + + expect(masked).toBe('invalid://user:***@somewhere'); + }); +}); + +describe('validateDatabaseUrl', () => { + it('should return URL if valid', () => { + const url = 'postgres://user:pass@localhost/db'; + const result = validateDatabaseUrl(url); + + expect(result).toBe(url); + }); + + it('should throw if URL is undefined', () => { + expect(() => validateDatabaseUrl(undefined)).toThrow('DATABASE_URL is required'); + }); + + it('should throw if URL is empty string', () => { + expect(() => validateDatabaseUrl('')).toThrow('DATABASE_URL is required'); + }); +}); + +describe('getConnectionErrorHelp', () => { + it('should detect DNS error for Direct Connection (IPv6 issue)', () => { + const error = new Error('getaddrinfo ENOTFOUND db.abc123.supabase.co'); + (error as NodeJS.ErrnoException).code = 'ENOTFOUND'; + const databaseUrl = 'postgres://postgres:pass@db.abc123.supabase.co:5432/postgres'; + + const help = getConnectionErrorHelp(error, databaseUrl); + + expect(help.isKnownError).toBe(true); + expect(help.title).toContain('Direct Connection'); + expect(help.title).toContain('IPv6'); + expect(help.explanation).toContain('IPv6'); + expect(help.suggestions.some(s => s.includes('Session Pooler'))).toBe(true); + expect(help.docsUrl).toBeDefined(); + }); + + it('should detect generic DNS error', () => { + const error = new Error('getaddrinfo ENOTFOUND somehost.example.com'); + (error as NodeJS.ErrnoException).code = 'ENOTFOUND'; + + const help = getConnectionErrorHelp(error); + + expect(help.isKnownError).toBe(true); + expect(help.title).toBe('DNS Resolution Failed'); + expect(help.explanation).toContain('somehost.example.com'); + }); + + it('should detect connection refused error', () => { + const error = new Error('connect ECONNREFUSED 127.0.0.1:5432'); + (error as NodeJS.ErrnoException).code = 'ECONNREFUSED'; + + const help = getConnectionErrorHelp(error); + + expect(help.isKnownError).toBe(true); + expect(help.title).toBe('Connection Refused'); + expect(help.suggestions.some(s => s.toLowerCase().includes('port'))).toBe(true); + }); + + it('should detect connection timeout', () => { + const error = new Error('connect ETIMEDOUT'); + (error as NodeJS.ErrnoException).code = 'ETIMEDOUT'; + + const help = getConnectionErrorHelp(error); + + expect(help.isKnownError).toBe(true); + expect(help.title).toBe('Connection Timeout'); + }); + + it('should detect authentication failure', () => { + const error = new Error('password authentication failed for user "postgres"'); + + const help = getConnectionErrorHelp(error); + + expect(help.isKnownError).toBe(true); + expect(help.title).toBe('Authentication Failed'); + expect(help.suggestions.some(s => s.toLowerCase().includes('password'))).toBe(true); + }); + + it('should detect SSL error', () => { + const error = new Error('SSL connection required'); + + const help = getConnectionErrorHelp(error); + + expect(help.isKnownError).toBe(true); + expect(help.title).toBe('SSL Connection Error'); + expect(help.docsUrl).toContain('ssl'); + }); + + it('should detect prepared statement error on Transaction Pooler', () => { + const error = new Error('prepared statement "s1" already exists'); + const databaseUrl = 'postgres://postgres.abc123:pass@aws-0-us-east-1.pooler.supabase.com:6543/postgres'; + + const help = getConnectionErrorHelp(error, databaseUrl); + + expect(help.isKnownError).toBe(true); + expect(help.title).toContain('Prepared Statement'); + expect(help.suggestions.some(s => s.includes('5432'))).toBe(true); + }); + + it('should detect database not found error', () => { + const error = new Error('database "mydb" does not exist'); + + const help = getConnectionErrorHelp(error); + + expect(help.isKnownError).toBe(true); + expect(help.title).toBe('Database Not Found'); + }); + + it('should detect too many connections error', () => { + const error = new Error('too many connections for role'); + + const help = getConnectionErrorHelp(error); + + expect(help.isKnownError).toBe(true); + expect(help.title).toBe('Too Many Connections'); + }); + + it('should return generic error for unknown errors', () => { + const error = new Error('Some completely unknown error'); + + const help = getConnectionErrorHelp(error); + + expect(help.isKnownError).toBe(false); + expect(help.title).toBe('Connection Error'); + expect(help.explanation).toBe('Some completely unknown error'); + }); +}); + +describe('formatConnectionErrorHelp', () => { + it('should format help with all fields', () => { + const help = { + isKnownError: true, + title: 'Test Error', + explanation: 'This is a test', + suggestions: ['Try this', 'Or this'], + docsUrl: 'https://example.com/docs', + }; + + const formatted = formatConnectionErrorHelp(help); + + expect(formatted).toContain('Test Error'); + expect(formatted).toContain('This is a test'); + expect(formatted).toContain('Try this'); + expect(formatted).toContain('Or this'); + expect(formatted).toContain('https://example.com/docs'); + expect(formatted).toContain('═'); // Border characters + }); + + it('should format help without docsUrl', () => { + const help = { + isKnownError: true, + title: 'Test Error', + explanation: 'This is a test', + suggestions: [], + }; + + const formatted = formatConnectionErrorHelp(help); + + expect(formatted).toContain('Test Error'); + expect(formatted).not.toContain('Documentation:'); + }); +}); diff --git a/tests/executor.test.ts b/tests/executor.test.ts new file mode 100644 index 0000000..5ad2097 --- /dev/null +++ b/tests/executor.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { SqlExecutor } from '../src/core/executor.js'; +import { SilentLogger } from '../src/core/logger.js'; +import type { ConnectionConfig, Logger } from '../src/types.js'; +import { Client } from 'pg'; +import { clearMockInstances, getLastMockClient } from './setup.js'; + +describe('SqlExecutor', () => { + let executor: SqlExecutor; + let mockLogger: Logger; + let testDir: string; + const mockConfig: ConnectionConfig = { + host: 'localhost', + port: 5432, + database: 'testdb', + user: 'testuser', + password: 'testpass', + ssl: false, + }; + + beforeEach(async () => { + vi.clearAllMocks(); + clearMockInstances(); + mockLogger = new SilentLogger(); + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'executor-test-')); + executor = new SqlExecutor(mockConfig, mockLogger); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + describe('connect', () => { + it('should connect to database', async () => { + await executor.connect(); + + expect(Client).toHaveBeenCalledWith({ + host: 'localhost', + port: 5432, + database: 'testdb', + user: 'testuser', + password: 'testpass', + ssl: false, + }); + }); + + it('should set up notice handler', async () => { + const onNotice = vi.fn(); + const executorWithNotice = new SqlExecutor(mockConfig, mockLogger, onNotice); + + await executorWithNotice.connect(); + + const mockInstance = getLastMockClient(); + expect(mockInstance?.on).toHaveBeenCalledWith('notice', expect.any(Function)); + }); + }); + + describe('disconnect', () => { + it('should close database connection', async () => { + await executor.connect(); + const mockInstance = getLastMockClient(); + await executor.disconnect(); + + expect(mockInstance?.end).toHaveBeenCalled(); + }); + + it('should handle disconnect when not connected', async () => { + await expect(executor.disconnect()).resolves.not.toThrow(); + }); + }); + + describe('beginTransaction', () => { + it('should start a transaction', async () => { + await executor.connect(); + const mockInstance = getLastMockClient(); + await executor.beginTransaction(); + + expect(mockInstance?.query).toHaveBeenCalledWith('BEGIN'); + }); + + it('should throw if not connected', async () => { + await expect(executor.beginTransaction()).rejects.toThrow( + 'Database not connected' + ); + }); + }); + + describe('commit', () => { + it('should commit the transaction', async () => { + await executor.connect(); + const mockInstance = getLastMockClient(); + await executor.commit(); + + expect(mockInstance?.query).toHaveBeenCalledWith('COMMIT'); + }); + + it('should throw if not connected', async () => { + await expect(executor.commit()).rejects.toThrow('Database not connected'); + }); + }); + + describe('rollback', () => { + it('should rollback the transaction', async () => { + await executor.connect(); + const mockInstance = getLastMockClient(); + await executor.rollback(); + + expect(mockInstance?.query).toHaveBeenCalledWith('ROLLBACK'); + }); + + it('should throw if not connected', async () => { + await expect(executor.rollback()).rejects.toThrow('Database not connected'); + }); + }); + + describe('createSavepoint', () => { + it('should create a savepoint', async () => { + await executor.connect(); + const mockInstance = getLastMockClient(); + await executor.createSavepoint('test_savepoint'); + + expect(mockInstance?.query).toHaveBeenCalledWith('SAVEPOINT "test_savepoint"'); + }); + + it('should throw if not connected', async () => { + await expect(executor.createSavepoint('sp')).rejects.toThrow( + 'Database not connected' + ); + }); + }); + + describe('rollbackToSavepoint', () => { + it('should rollback to savepoint and return true', async () => { + await executor.connect(); + const mockInstance = getLastMockClient(); + const result = await executor.rollbackToSavepoint('test_savepoint'); + + expect(result).toBe(true); + expect(mockInstance?.query).toHaveBeenCalledWith( + 'ROLLBACK TO SAVEPOINT "test_savepoint"' + ); + }); + + it('should return false on error', async () => { + await executor.connect(); + const mockInstance = getLastMockClient(); + mockInstance!.query.mockRejectedValueOnce(new Error('Savepoint not found')); + + const result = await executor.rollbackToSavepoint('nonexistent'); + + expect(result).toBe(false); + }); + }); + + describe('releaseSavepoint', () => { + it('should release a savepoint', async () => { + await executor.connect(); + const mockInstance = getLastMockClient(); + await executor.releaseSavepoint('test_savepoint'); + + expect(mockInstance?.query).toHaveBeenCalledWith( + 'RELEASE SAVEPOINT "test_savepoint"' + ); + }); + + it('should throw if not connected', async () => { + await expect(executor.releaseSavepoint('sp')).rejects.toThrow( + 'Database not connected' + ); + }); + }); + + describe('executeFile', () => { + it('should execute SQL file successfully', async () => { + const filePath = path.join(testDir, 'test.sql'); + fs.writeFileSync(filePath, 'SELECT 1;'); + + await executor.connect(); + const result = await executor.executeFile(filePath, 0); + + expect(result.success).toBe(true); + expect(result.fileName).toBe('test.sql'); + expect(result.filePath).toBe(filePath); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }); + + it('should create and release savepoint on success', async () => { + const filePath = path.join(testDir, 'test.sql'); + fs.writeFileSync(filePath, 'SELECT 1;'); + + await executor.connect(); + const mockInstance = getLastMockClient(); + await executor.executeFile(filePath, 0); + + // Should have created savepoint + expect(mockInstance?.query).toHaveBeenCalledWith( + expect.stringContaining('SAVEPOINT') + ); + + // Should have released savepoint + expect(mockInstance?.query).toHaveBeenCalledWith( + expect.stringContaining('RELEASE SAVEPOINT') + ); + }); + + it('should rollback to savepoint on error', async () => { + const filePath = path.join(testDir, 'test.sql'); + fs.writeFileSync(filePath, 'INVALID SQL;'); + + await executor.connect(); + const mockInstance = getLastMockClient(); + + // Make query fail for the SQL execution (but not for savepoint operations) + mockInstance!.query.mockImplementation((sql: string) => { + if (sql.includes('INVALID SQL')) { + return Promise.reject(new Error('syntax error')); + } + return Promise.resolve({ rows: [] }); + }); + + const result = await executor.executeFile(filePath, 0); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.message).toContain('syntax error'); + }); + + it('should include error details in result', async () => { + const filePath = path.join(testDir, 'test.sql'); + fs.writeFileSync(filePath, 'SELECT * FROM nonexistent;'); + + await executor.connect(); + const mockInstance = getLastMockClient(); + + const pgError = new Error('relation "nonexistent" does not exist') as Error & { + code?: string; + detail?: string; + hint?: string; + }; + pgError.code = '42P01'; + pgError.hint = 'Check table name'; + + mockInstance!.query.mockImplementation((sql: string) => { + if (sql.includes('nonexistent')) { + return Promise.reject(pgError); + } + return Promise.resolve({ rows: [] }); + }); + + const result = await executor.executeFile(filePath, 0); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('42P01'); + expect(result.error?.hint).toBe('Check table name'); + expect(result.error?.fileName).toBe('test.sql'); + }); + + it('should throw if not connected', async () => { + const filePath = path.join(testDir, 'test.sql'); + fs.writeFileSync(filePath, 'SELECT 1;'); + + await expect(executor.executeFile(filePath, 0)).rejects.toThrow( + 'Database not connected' + ); + }); + + it('should generate unique savepoint name based on file and index', async () => { + const filePath = path.join(testDir, 'test.sql'); + fs.writeFileSync(filePath, 'SELECT 1;'); + + await executor.connect(); + const result = await executor.executeFile(filePath, 5); + + expect(result.savepointName).toContain('sp_'); + expect(result.savepointName).toContain('test'); + expect(result.savepointName).toContain('5'); + }); + }); +}); + +describe('SqlExecutor - error formatting', () => { + let executor: SqlExecutor; + let testDir: string; + const mockConfig: ConnectionConfig = { + host: 'localhost', + port: 5432, + database: 'testdb', + user: 'testuser', + password: 'testpass', + ssl: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + clearMockInstances(); + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'error-test-')); + executor = new SqlExecutor(mockConfig, new SilentLogger()); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should format non-Error objects', async () => { + const filePath = path.join(testDir, 'test.sql'); + fs.writeFileSync(filePath, 'SELECT 1;'); + + await executor.connect(); + const mockInstance = getLastMockClient(); + + mockInstance!.query.mockImplementation((sql: string) => { + if (sql.includes('SELECT 1')) { + return Promise.reject('String error'); // Non-Error object + } + return Promise.resolve({ rows: [] }); + }); + + const result = await executor.executeFile(filePath, 0); + + expect(result.success).toBe(false); + expect(result.error?.message).toBe('String error'); + }); +}); diff --git a/tests/file-scanner.extended.test.ts b/tests/file-scanner.extended.test.ts new file mode 100644 index 0000000..9e07b6f --- /dev/null +++ b/tests/file-scanner.extended.test.ts @@ -0,0 +1,295 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + scanSqlFiles, + readSqlFile, + createSavepointName, + DEFAULT_FILE_PATTERN, + DEFAULT_IGNORE_PATTERN, +} from '../src/core/file-scanner.js'; + +describe('readSqlFile', () => { + let testDir: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sql-reader-test-')); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should read SQL file content', () => { + const filePath = path.join(testDir, 'test.sql'); + const content = 'SELECT * FROM users;'; + fs.writeFileSync(filePath, content); + + const result = readSqlFile(filePath); + + expect(result).toBe(content); + }); + + it('should read multi-line SQL file', () => { + const filePath = path.join(testDir, 'multi.sql'); + const content = ` +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) +); + +INSERT INTO users (name) VALUES ('test'); + `.trim(); + fs.writeFileSync(filePath, content); + + const result = readSqlFile(filePath); + + expect(result).toBe(content); + }); + + it('should handle UTF-8 content', () => { + const filePath = path.join(testDir, 'utf8.sql'); + const content = "INSERT INTO messages (text) VALUES ('Hello 世界 🌍');"; + fs.writeFileSync(filePath, content, 'utf8'); + + const result = readSqlFile(filePath); + + expect(result).toBe(content); + }); + + it('should throw error for non-existent file', () => { + const filePath = path.join(testDir, 'nonexistent.sql'); + + expect(() => readSqlFile(filePath)).toThrow('Failed to read SQL file'); + }); + + it('should include file path in error message', () => { + const filePath = path.join(testDir, 'nonexistent.sql'); + + expect(() => readSqlFile(filePath)).toThrow(filePath); + }); + + it('should read empty file', () => { + const filePath = path.join(testDir, 'empty.sql'); + fs.writeFileSync(filePath, ''); + + const result = readSqlFile(filePath); + + expect(result).toBe(''); + }); + + it('should read file with only whitespace', () => { + const filePath = path.join(testDir, 'whitespace.sql'); + const content = ' \n\t\n '; + fs.writeFileSync(filePath, content); + + const result = readSqlFile(filePath); + + expect(result).toBe(content); + }); + + it('should read file with SQL comments', () => { + const filePath = path.join(testDir, 'comments.sql'); + const content = ` +-- This is a comment +/* Multi-line + comment */ +SELECT 1; -- inline comment + `.trim(); + fs.writeFileSync(filePath, content); + + const result = readSqlFile(filePath); + + expect(result).toBe(content); + }); +}); + +describe('scanSqlFiles - extended', () => { + let testDir: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sql-scan-ext-test-')); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should handle empty directory', () => { + const result = scanSqlFiles(testDir, { + filePattern: DEFAULT_FILE_PATTERN, + ignorePattern: DEFAULT_IGNORE_PATTERN, + }); + + expect(result.files).toHaveLength(0); + expect(result.filePaths).toHaveLength(0); + expect(result.ignoredFiles).toHaveLength(0); + }); + + it('should handle directory with only ignored files', () => { + fs.writeFileSync(path.join(testDir, '_ignored_1.sql'), 'SELECT 1;'); + fs.writeFileSync(path.join(testDir, '_ignored_2.sql'), 'SELECT 2;'); + fs.writeFileSync(path.join(testDir, 'README.sql'), 'SELECT 3;'); + + const result = scanSqlFiles(testDir, { + filePattern: DEFAULT_FILE_PATTERN, + ignorePattern: DEFAULT_IGNORE_PATTERN, + }); + + expect(result.files).toHaveLength(0); + expect(result.ignoredFiles).toHaveLength(3); + }); + + it('should handle directory with non-SQL files only', () => { + fs.writeFileSync(path.join(testDir, 'script.js'), 'console.log()'); + fs.writeFileSync(path.join(testDir, 'data.json'), '{}'); + fs.writeFileSync(path.join(testDir, 'readme.md'), '# Readme'); + + const result = scanSqlFiles(testDir, { + filePattern: DEFAULT_FILE_PATTERN, + ignorePattern: DEFAULT_IGNORE_PATTERN, + }); + + expect(result.files).toHaveLength(0); + expect(result.ignoredFiles).toHaveLength(0); + }); + + it('should use custom file pattern', () => { + fs.writeFileSync(path.join(testDir, 'script.psql'), 'SELECT 1;'); + fs.writeFileSync(path.join(testDir, 'script.sql'), 'SELECT 2;'); + + const result = scanSqlFiles(testDir, { + filePattern: /\.psql$/, + ignorePattern: DEFAULT_IGNORE_PATTERN, + }); + + expect(result.files).toHaveLength(1); + expect(result.files[0]).toBe('script.psql'); + }); + + it('should use custom ignore pattern', () => { + fs.writeFileSync(path.join(testDir, 'test_script.sql'), 'SELECT 1;'); + fs.writeFileSync(path.join(testDir, 'prod_script.sql'), 'SELECT 2;'); + + const result = scanSqlFiles(testDir, { + filePattern: DEFAULT_FILE_PATTERN, + ignorePattern: /^test_/, + }); + + expect(result.files).toHaveLength(1); + expect(result.files[0]).toBe('prod_script.sql'); + expect(result.ignoredFiles).toContain('test_script.sql'); + }); + + it('should handle files with numbers in various positions', () => { + fs.writeFileSync(path.join(testDir, '01_first.sql'), ''); + fs.writeFileSync(path.join(testDir, '02_second.sql'), ''); + fs.writeFileSync(path.join(testDir, '10_tenth.sql'), ''); + fs.writeFileSync(path.join(testDir, '100_hundredth.sql'), ''); + + const result = scanSqlFiles(testDir, { + filePattern: DEFAULT_FILE_PATTERN, + ignorePattern: DEFAULT_IGNORE_PATTERN, + }); + + expect(result.files).toEqual([ + '01_first.sql', + '02_second.sql', + '100_hundredth.sql', + '10_tenth.sql', + ]); + }); + + it('should handle mixed case filenames', () => { + fs.writeFileSync(path.join(testDir, 'Script.SQL'), ''); + fs.writeFileSync(path.join(testDir, 'UPPER.sql'), ''); + fs.writeFileSync(path.join(testDir, 'lower.sql'), ''); + + const result = scanSqlFiles(testDir, { + filePattern: /\.sql$/i, // Case insensitive + ignorePattern: DEFAULT_IGNORE_PATTERN, + }); + + expect(result.files.length).toBeGreaterThanOrEqual(2); + }); + + it('should resolve relative paths to absolute', () => { + fs.writeFileSync(path.join(testDir, 'test.sql'), ''); + + const result = scanSqlFiles(testDir, { + filePattern: DEFAULT_FILE_PATTERN, + ignorePattern: DEFAULT_IGNORE_PATTERN, + }); + + result.filePaths.forEach((fp) => { + expect(path.isAbsolute(fp)).toBe(true); + }); + }); + + it('should handle special characters in directory path', () => { + const specialDir = path.join(testDir, 'dir with spaces'); + fs.mkdirSync(specialDir); + fs.writeFileSync(path.join(specialDir, 'test.sql'), 'SELECT 1;'); + + const result = scanSqlFiles(specialDir, { + filePattern: DEFAULT_FILE_PATTERN, + ignorePattern: DEFAULT_IGNORE_PATTERN, + }); + + expect(result.files).toHaveLength(1); + }); +}); + +describe('createSavepointName - extended', () => { + it('should handle empty filename', () => { + const name = createSavepointName('', 0); + + expect(name).toBe('sp__0'); + expect(name).toMatch(/^[a-zA-Z_]/); // Valid SQL identifier start + }); + + it('should handle filename with only special characters', () => { + const name = createSavepointName('---...---', 0); + + expect(name).toBe('sp___________0'); + }); + + it('should handle very long filename', () => { + const longName = 'a'.repeat(200) + '.sql'; + const name = createSavepointName(longName, 0); + + expect(name.length).toBeGreaterThan(200); + expect(name).toMatch(/^sp_a+_sql_0$/); + }); + + it('should handle filename with unicode characters', () => { + const name = createSavepointName('日本語.sql', 0); + + // Unicode chars should be replaced with underscores + expect(name).toMatch(/^sp_[_]+_sql_0$/); + }); + + it('should handle numeric filename', () => { + const name = createSavepointName('12345.sql', 0); + + expect(name).toBe('sp_12345_sql_0'); + }); + + it('should produce unique names for similar files', () => { + const name1 = createSavepointName('file-1.sql', 0); + const _name2 = createSavepointName('file_1.sql', 0); + + // Both - and _ become _, so they should differ only if index is different + // Actually they'll be the same since both produce sp_file_1_sql_0 + // Let's test with different indices + const name3 = createSavepointName('file-1.sql', 1); + expect(name1).not.toBe(name3); + }); + + it('should handle large index numbers', () => { + const name = createSavepointName('test.sql', 999999); + + expect(name).toBe('sp_test_sql_999999'); + }); +}); diff --git a/tests/file-scanner.test.ts b/tests/file-scanner.test.ts new file mode 100644 index 0000000..4295cd9 --- /dev/null +++ b/tests/file-scanner.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + scanSqlFiles, + createSavepointName, + DEFAULT_FILE_PATTERN, + DEFAULT_IGNORE_PATTERN, +} from '../src/core/file-scanner.js'; + +describe('scanSqlFiles', () => { + let testDir: string; + + beforeEach(() => { + // Create temp directory with test files + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sql-runner-test-')); + + // Create test SQL files + fs.writeFileSync(path.join(testDir, '00_setup.sql'), 'SELECT 1;'); + fs.writeFileSync(path.join(testDir, '01_tables.sql'), 'SELECT 2;'); + fs.writeFileSync(path.join(testDir, '02_indexes.sql'), 'SELECT 3;'); + fs.writeFileSync(path.join(testDir, '_ignored_test.sql'), 'SELECT 4;'); + fs.writeFileSync(path.join(testDir, 'README.md'), '# Readme'); + }); + + afterEach(() => { + // Clean up temp directory + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should find SQL files in directory', () => { + const result = scanSqlFiles(testDir, { + filePattern: DEFAULT_FILE_PATTERN, + ignorePattern: DEFAULT_IGNORE_PATTERN, + }); + + expect(result.files).toHaveLength(3); + expect(result.files).toContain('00_setup.sql'); + expect(result.files).toContain('01_tables.sql'); + expect(result.files).toContain('02_indexes.sql'); + }); + + it('should sort files alphabetically', () => { + const result = scanSqlFiles(testDir, { + filePattern: DEFAULT_FILE_PATTERN, + ignorePattern: DEFAULT_IGNORE_PATTERN, + }); + + expect(result.files[0]).toBe('00_setup.sql'); + expect(result.files[1]).toBe('01_tables.sql'); + expect(result.files[2]).toBe('02_indexes.sql'); + }); + + it('should ignore files matching ignore pattern', () => { + const result = scanSqlFiles(testDir, { + filePattern: DEFAULT_FILE_PATTERN, + ignorePattern: DEFAULT_IGNORE_PATTERN, + }); + + expect(result.ignoredFiles).toHaveLength(1); + expect(result.ignoredFiles).toContain('_ignored_test.sql'); + expect(result.files).not.toContain('_ignored_test.sql'); + }); + + it('should provide full paths', () => { + const result = scanSqlFiles(testDir, { + filePattern: DEFAULT_FILE_PATTERN, + ignorePattern: DEFAULT_IGNORE_PATTERN, + }); + + expect(result.filePaths).toHaveLength(3); + result.filePaths.forEach((fp) => { + expect(path.isAbsolute(fp)).toBe(true); + expect(fs.existsSync(fp)).toBe(true); + }); + }); + + it('should throw if directory does not exist', () => { + expect(() => + scanSqlFiles('/nonexistent/path', { + filePattern: DEFAULT_FILE_PATTERN, + ignorePattern: DEFAULT_IGNORE_PATTERN, + }) + ).toThrow('SQL directory not found'); + }); + + it('should throw if path is not a directory', () => { + const filePath = path.join(testDir, '00_setup.sql'); + + expect(() => + scanSqlFiles(filePath, { + filePattern: DEFAULT_FILE_PATTERN, + ignorePattern: DEFAULT_IGNORE_PATTERN, + }) + ).toThrow('Path is not a directory'); + }); +}); + +describe('createSavepointName', () => { + it('should create valid savepoint name', () => { + const name = createSavepointName('01_tables.sql', 0); + + expect(name).toBe('sp_01_tables_sql_0'); + expect(name).toMatch(/^[a-zA-Z_][a-zA-Z0-9_]*$/); + }); + + it('should handle special characters', () => { + const name = createSavepointName('my-file (test).sql', 5); + + expect(name).toBe('sp_my_file__test__sql_5'); + }); + + it('should include index for uniqueness', () => { + const name1 = createSavepointName('file.sql', 0); + const name2 = createSavepointName('file.sql', 1); + + expect(name1).not.toBe(name2); + }); +}); diff --git a/tests/integration.test.ts b/tests/integration.test.ts new file mode 100644 index 0000000..d05427f --- /dev/null +++ b/tests/integration.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { SqlRunner } from '../src/core/runner.js'; +import { SilentLogger } from '../src/core/logger.js'; +import { clearMockInstances } from './setup.js'; + +describe('Integration Tests', () => { + let testDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + clearMockInstances(); + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'integration-test-')); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + describe('full execution flow', () => { + it('should execute multiple files in correct order', async () => { + // Create SQL files with different prefixes + fs.writeFileSync(path.join(testDir, '01_setup.sql'), 'CREATE TABLE test1;'); + fs.writeFileSync(path.join(testDir, '02_data.sql'), 'INSERT INTO test1 VALUES (1);'); + fs.writeFileSync(path.join(testDir, '03_indexes.sql'), 'CREATE INDEX ON test1;'); + + const executionOrder: string[] = []; + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + requireConfirmation: false, + onBeforeFile: (fileName) => { + executionOrder.push(fileName); + }, + }); + + const summary = await runner.run({ skipConfirmation: true }); + + expect(summary.totalFiles).toBe(3); + expect(executionOrder).toEqual([ + '01_setup.sql', + '02_data.sql', + '03_indexes.sql', + ]); + }); + + it('should handle mixed file types with ignore pattern', async () => { + fs.writeFileSync(path.join(testDir, '01_main.sql'), 'SELECT 1;'); + fs.writeFileSync(path.join(testDir, '_ignored_test.sql'), 'SELECT 2;'); + fs.writeFileSync(path.join(testDir, 'README.md'), '# Documentation'); + fs.writeFileSync(path.join(testDir, 'notes.txt'), 'Some notes'); + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + requireConfirmation: false, + }); + + const summary = await runner.run({ skipConfirmation: true }); + + expect(summary.totalFiles).toBe(1); + expect(summary.results[0].fileName).toBe('01_main.sql'); + expect(summary.ignoredFiles).toContain('_ignored_test.sql'); + }); + + it('should calculate total duration', async () => { + fs.writeFileSync(path.join(testDir, '01_test.sql'), 'SELECT 1;'); + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + requireConfirmation: false, + }); + + const summary = await runner.run({ skipConfirmation: true }); + + expect(summary.totalDurationMs).toBeGreaterThanOrEqual(0); + expect(typeof summary.totalDurationMs).toBe('number'); + }); + }); + + describe('callback integration', () => { + it('should call all callbacks in correct order', async () => { + fs.writeFileSync(path.join(testDir, '01_test.sql'), 'SELECT 1;'); + + const callOrder: string[] = []; + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + requireConfirmation: false, + onBeforeFile: () => callOrder.push('before'), + onAfterFile: () => callOrder.push('after'), + onComplete: () => callOrder.push('complete'), + }); + + await runner.run({ skipConfirmation: true }); + + expect(callOrder).toEqual(['before', 'after', 'complete']); + }); + + it('should call callbacks for each file', async () => { + fs.writeFileSync(path.join(testDir, '01_first.sql'), 'SELECT 1;'); + fs.writeFileSync(path.join(testDir, '02_second.sql'), 'SELECT 2;'); + fs.writeFileSync(path.join(testDir, '03_third.sql'), 'SELECT 3;'); + + let beforeCount = 0; + let afterCount = 0; + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + requireConfirmation: false, + onBeforeFile: () => beforeCount++, + onAfterFile: () => afterCount++, + }); + + await runner.run({ skipConfirmation: true }); + + expect(beforeCount).toBe(3); + expect(afterCount).toBe(3); + }); + + it('should provide correct file info to callbacks', async () => { + fs.writeFileSync(path.join(testDir, '01_test.sql'), 'SELECT 1;'); + + let capturedBeforeInfo: { fileName: string; index: number; total: number } | null = null; + let capturedAfterInfo: { fileName: string; success: boolean } | null = null; + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + requireConfirmation: false, + onBeforeFile: (fileName, index, total) => { + capturedBeforeInfo = { fileName, index, total }; + }, + onAfterFile: (result) => { + capturedAfterInfo = { fileName: result.fileName, success: result.success }; + }, + }); + + await runner.run({ skipConfirmation: true }); + + expect(capturedBeforeInfo).toEqual({ + fileName: '01_test.sql', + index: 0, + total: 1, + }); + expect(capturedAfterInfo?.fileName).toBe('01_test.sql'); + expect(capturedAfterInfo?.success).toBe(true); + }); + }); + + describe('file filtering', () => { + it('should combine onlyFiles and skipFiles correctly', async () => { + fs.writeFileSync(path.join(testDir, '01_a.sql'), 'SELECT 1;'); + fs.writeFileSync(path.join(testDir, '02_b.sql'), 'SELECT 2;'); + fs.writeFileSync(path.join(testDir, '03_c.sql'), 'SELECT 3;'); + fs.writeFileSync(path.join(testDir, '04_d.sql'), 'SELECT 4;'); + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + requireConfirmation: false, + }); + + // Only run 02 and 03, but skip 03 + const summary = await runner.run({ + skipConfirmation: true, + onlyFiles: ['02_b.sql', '03_c.sql'], + skipFiles: ['03_c.sql'], + }); + + expect(summary.totalFiles).toBe(1); + expect(summary.results[0].fileName).toBe('02_b.sql'); + }); + + it('should handle non-matching onlyFiles', async () => { + fs.writeFileSync(path.join(testDir, '01_test.sql'), 'SELECT 1;'); + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + requireConfirmation: false, + }); + + const summary = await runner.run({ + skipConfirmation: true, + onlyFiles: ['nonexistent.sql'], + }); + + expect(summary.totalFiles).toBe(0); + }); + + it('should handle all files skipped', async () => { + fs.writeFileSync(path.join(testDir, '01_test.sql'), 'SELECT 1;'); + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + requireConfirmation: false, + }); + + const summary = await runner.run({ + skipConfirmation: true, + skipFiles: ['01_test.sql'], + }); + + expect(summary.totalFiles).toBe(0); + }); + }); + + describe('error scenarios', () => { + it('should handle missing SQL directory', async () => { + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: '/nonexistent/path', + logger: new SilentLogger(), + requireConfirmation: false, + }); + + await expect(runner.run({ skipConfirmation: true })).rejects.toThrow( + 'SQL directory not found' + ); + }); + + it('should call onError callback on unexpected error', async () => { + const onError = vi.fn(); + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: '/nonexistent/path', + logger: new SilentLogger(), + requireConfirmation: false, + onError, + }); + + await expect(runner.run({ skipConfirmation: true })).rejects.toThrow(); + + expect(onError).toHaveBeenCalled(); + }); + }); + + describe('summary structure', () => { + it('should return complete summary structure', async () => { + fs.writeFileSync(path.join(testDir, '01_test.sql'), 'SELECT 1;'); + fs.writeFileSync(path.join(testDir, '_ignored.sql'), 'SELECT 2;'); + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + requireConfirmation: false, + }); + + const summary = await runner.run({ skipConfirmation: true }); + + // Verify all expected properties exist + expect(summary).toHaveProperty('totalFiles'); + expect(summary).toHaveProperty('successfulFiles'); + expect(summary).toHaveProperty('failedFiles'); + expect(summary).toHaveProperty('totalDurationMs'); + expect(summary).toHaveProperty('results'); + expect(summary).toHaveProperty('allSuccessful'); + expect(summary).toHaveProperty('committed'); + expect(summary).toHaveProperty('ignoredFiles'); + + // Verify types + expect(typeof summary.totalFiles).toBe('number'); + expect(typeof summary.successfulFiles).toBe('number'); + expect(typeof summary.failedFiles).toBe('number'); + expect(typeof summary.totalDurationMs).toBe('number'); + expect(Array.isArray(summary.results)).toBe(true); + expect(typeof summary.allSuccessful).toBe('boolean'); + expect(typeof summary.committed).toBe('boolean'); + expect(Array.isArray(summary.ignoredFiles)).toBe(true); + }); + + it('should return complete result structure for each file', async () => { + fs.writeFileSync(path.join(testDir, '01_test.sql'), 'SELECT 1;'); + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + requireConfirmation: false, + }); + + const summary = await runner.run({ skipConfirmation: true }); + const result = summary.results[0]; + + expect(result).toHaveProperty('fileName'); + expect(result).toHaveProperty('filePath'); + expect(result).toHaveProperty('success'); + expect(result).toHaveProperty('durationMs'); + expect(result).toHaveProperty('savepointName'); + + expect(typeof result.fileName).toBe('string'); + expect(typeof result.filePath).toBe('string'); + expect(typeof result.success).toBe('boolean'); + expect(typeof result.durationMs).toBe('number'); + expect(typeof result.savepointName).toBe('string'); + }); + }); +}); diff --git a/tests/logger.test.ts b/tests/logger.test.ts new file mode 100644 index 0000000..9750af9 --- /dev/null +++ b/tests/logger.test.ts @@ -0,0 +1,289 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + ConsoleLogger, + SilentLogger, + createLogger, +} from '../src/core/logger.js'; + +describe('ConsoleLogger', () => { + let consoleSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('should log info messages', () => { + const logger = new ConsoleLogger(); + logger.info('Test info message'); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + // New format uses symbols: ℹ for info + expect(consoleSpy.mock.calls[0][0]).toContain('Test info message'); + }); + + it('should log success messages', () => { + const logger = new ConsoleLogger(); + logger.success('Test success message'); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + // New format uses symbols: ✓ for success + expect(consoleSpy.mock.calls[0][0]).toContain('Test success message'); + }); + + it('should log warning messages', () => { + const logger = new ConsoleLogger(); + logger.warning('Test warning message'); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + // New format uses symbols: ⚠ for warning + expect(consoleSpy.mock.calls[0][0]).toContain('Test warning message'); + }); + + it('should log error messages to stderr', () => { + const logger = new ConsoleLogger(); + logger.error('Test error message'); + + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + // New format uses symbols: ✗ for error + expect(consoleErrorSpy.mock.calls[0][0]).toContain('Test error message'); + }); + + it('should log debug messages', () => { + const logger = new ConsoleLogger(); + logger.debug('Test debug message'); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + // New format uses symbols: · for debug + expect(consoleSpy.mock.calls[0][0]).toContain('Test debug message'); + }); + + it('should include symbol indicators in messages', () => { + const logger = new ConsoleLogger(); + logger.info('Test message'); + + // Should contain the info symbol ℹ + expect(consoleSpy.mock.calls[0][0]).toContain('ℹ'); + }); + + it('should apply colors by default (TTY)', () => { + // Mock process.stdout.isTTY to true + const originalIsTTY = process.stdout.isTTY; + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + + const logger = new ConsoleLogger(); + logger.info('Test message'); + + // Should contain ANSI color codes when TTY + expect(consoleSpy.mock.calls[0][0]).toContain('\x1b['); + + // Restore + Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, writable: true }); + }); + + it('should not apply colors when NO_COLOR is set', () => { + const originalNoColor = process.env.NO_COLOR; + process.env.NO_COLOR = '1'; + + const logger = new ConsoleLogger(); + logger.info('Test message'); + + // Should not contain ANSI color codes + expect(consoleSpy.mock.calls[0][0]).not.toContain('\x1b['); + + // Restore + if (originalNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = originalNoColor; + } + }); + + describe('file logging', () => { + let testLogDir: string; + + beforeEach(() => { + testLogDir = fs.mkdtempSync(path.join(os.tmpdir(), 'logger-test-')); + }); + + afterEach(() => { + fs.rmSync(testLogDir, { recursive: true, force: true }); + }); + + it('should create log directory if it does not exist', () => { + const logDir = path.join(testLogDir, 'nested', 'logs'); + const logger = new ConsoleLogger({ logDirectory: logDir }); + logger.info('Test message'); + + expect(fs.existsSync(logDir)).toBe(true); + }); + + it('should write logs to file', () => { + const logger = new ConsoleLogger({ logDirectory: testLogDir }); + logger.info('Test log entry'); + + const logFile = path.join(testLogDir, 'sql-runner.log'); + expect(fs.existsSync(logFile)).toBe(true); + + const content = fs.readFileSync(logFile, 'utf8'); + expect(content).toContain('Test log entry'); + }); + + it('should write errors to both log and error file', () => { + const logger = new ConsoleLogger({ logDirectory: testLogDir }); + logger.error('Test error entry'); + + const logFile = path.join(testLogDir, 'sql-runner.log'); + const errorFile = path.join(testLogDir, 'sql-runner-error.log'); + + expect(fs.existsSync(logFile)).toBe(true); + expect(fs.existsSync(errorFile)).toBe(true); + + const logContent = fs.readFileSync(logFile, 'utf8'); + const errorContent = fs.readFileSync(errorFile, 'utf8'); + + expect(logContent).toContain('Test error entry'); + expect(errorContent).toContain('Test error entry'); + }); + + it('should not write to file when logDirectory is null', () => { + const logger = new ConsoleLogger({ logDirectory: null }); + logger.info('Test message'); + + const logFile = path.join(testLogDir, 'sql-runner.log'); + expect(fs.existsSync(logFile)).toBe(false); + }); + + it('should include timestamp in file logs', () => { + const logger = new ConsoleLogger({ logDirectory: testLogDir }); + logger.info('Test message'); + + const logFile = path.join(testLogDir, 'sql-runner.log'); + const content = fs.readFileSync(logFile, 'utf8'); + + // ISO timestamp pattern: 2024-01-01T00:00:00.000Z + expect(content).toMatch(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); + + it('should include log level in file logs', () => { + const logger = new ConsoleLogger({ logDirectory: testLogDir }); + logger.info('Test message'); + + const logFile = path.join(testLogDir, 'sql-runner.log'); + const content = fs.readFileSync(logFile, 'utf8'); + + expect(content).toContain('[INFO]'); + }); + + it('should handle file write errors gracefully', () => { + const logger = new ConsoleLogger({ logDirectory: testLogDir }); + logger.info('First message'); + + // Delete the log directory to cause write errors + fs.rmSync(testLogDir, { recursive: true, force: true }); + + // Should not throw, just silently fail file write + expect(() => { + logger.info('Second message after dir deleted'); + logger.error('Error after dir deleted'); + }).not.toThrow(); + }); + + it('should handle directory creation failure gracefully', () => { + // Use an invalid path that can't be created (Windows-specific invalid chars) + // On Unix, use a path under a non-writable location + const invalidPath = process.platform === 'win32' + ? 'Z:\\nonexistent\\invalid<>path' + : '/root/cannot-create-here-' + Date.now(); + + // Should not throw during construction + expect(() => { + const logger = new ConsoleLogger({ logDirectory: invalidPath }); + logger.info('Test message'); + }).not.toThrow(); + }); + }); +}); + +describe('SilentLogger', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('should not output anything for info', () => { + const logger = new SilentLogger(); + logger.info('Test message'); + + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it('should not output anything for success', () => { + const logger = new SilentLogger(); + logger.success('Test message'); + + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it('should not output anything for warning', () => { + const logger = new SilentLogger(); + logger.warning('Test message'); + + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it('should not output anything for error', () => { + const logger = new SilentLogger(); + logger.error('Test message'); + + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it('should not output anything for debug', () => { + const logger = new SilentLogger(); + logger.debug('Test message'); + + expect(consoleSpy).not.toHaveBeenCalled(); + }); +}); + +describe('createLogger', () => { + it('should create SilentLogger when silent is true', () => { + const logger = createLogger({ silent: true }); + + expect(logger).toBeInstanceOf(SilentLogger); + }); + + it('should create ConsoleLogger when silent is false', () => { + const logger = createLogger({ silent: false }); + + expect(logger).toBeInstanceOf(ConsoleLogger); + }); + + it('should create ConsoleLogger by default', () => { + const logger = createLogger({}); + + expect(logger).toBeInstanceOf(ConsoleLogger); + }); + + it('should pass logDirectory to ConsoleLogger', () => { + const testDir = '/tmp/test-logs'; + const logger = createLogger({ logDirectory: testDir }); + + expect(logger).toBeInstanceOf(ConsoleLogger); + }); +}); diff --git a/tests/runner.test.ts b/tests/runner.test.ts new file mode 100644 index 0000000..5418972 --- /dev/null +++ b/tests/runner.test.ts @@ -0,0 +1,390 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { SqlRunner, runSqlScripts } from '../src/core/runner.js'; +import { SilentLogger } from '../src/core/logger.js'; +import type { Logger } from '../src/types.js'; +import { clearMockInstances } from './setup.js'; + +describe('SqlRunner', () => { + let testDir: string; + let mockLogger: Logger; + + beforeEach(() => { + vi.clearAllMocks(); + clearMockInstances(); + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'runner-test-')); + mockLogger = new SilentLogger(); + + // Create test SQL files + fs.writeFileSync(path.join(testDir, '01_first.sql'), 'SELECT 1;'); + fs.writeFileSync(path.join(testDir, '02_second.sql'), 'SELECT 2;'); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + describe('constructor', () => { + it('should create instance with valid config', () => { + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: mockLogger, + }); + + expect(runner).toBeInstanceOf(SqlRunner); + }); + + it('should throw error if databaseUrl is missing', () => { + expect(() => { + new SqlRunner({ + databaseUrl: '', + sqlDirectory: testDir, + }); + }).toThrow('DATABASE_URL is required'); + }); + + it('should throw error if databaseUrl is undefined', () => { + expect(() => { + new SqlRunner({ + databaseUrl: undefined as unknown as string, + sqlDirectory: testDir, + }); + }).toThrow('DATABASE_URL is required'); + }); + + it('should use default values for optional config', () => { + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: mockLogger, + }); + + expect(runner).toBeDefined(); + }); + + it('should accept custom logger', () => { + const customLogger: Logger = { + info: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: customLogger, + }); + + expect(runner).toBeDefined(); + }); + }); + + describe('run', () => { + it('should return summary with no files when directory is empty', async () => { + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-')); + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: emptyDir, + logger: mockLogger, + requireConfirmation: false, + }); + + const summary = await runner.run({ skipConfirmation: true }); + + expect(summary.totalFiles).toBe(0); + expect(summary.allSuccessful).toBe(true); + expect(summary.committed).toBe(false); + + fs.rmSync(emptyDir, { recursive: true, force: true }); + }); + + it('should execute files in order', async () => { + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: mockLogger, + requireConfirmation: false, + }); + + const summary = await runner.run({ skipConfirmation: true }); + + expect(summary.totalFiles).toBe(2); + expect(summary.results[0].fileName).toBe('01_first.sql'); + expect(summary.results[1].fileName).toBe('02_second.sql'); + }); + + it('should respect onlyFiles option', async () => { + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: mockLogger, + requireConfirmation: false, + }); + + const summary = await runner.run({ + skipConfirmation: true, + onlyFiles: ['01_first.sql'], + }); + + expect(summary.totalFiles).toBe(1); + expect(summary.results[0].fileName).toBe('01_first.sql'); + }); + + it('should respect skipFiles option', async () => { + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: mockLogger, + requireConfirmation: false, + }); + + const summary = await runner.run({ + skipConfirmation: true, + skipFiles: ['01_first.sql'], + }); + + expect(summary.totalFiles).toBe(1); + expect(summary.results[0].fileName).toBe('02_second.sql'); + }); + + it('should handle dryRun option', async () => { + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: mockLogger, + requireConfirmation: false, + }); + + const summary = await runner.run({ + skipConfirmation: true, + dryRun: true, + }); + + expect(summary.totalFiles).toBe(0); + expect(summary.committed).toBe(false); + }); + + it('should call onBeforeFile callback', async () => { + const onBeforeFile = vi.fn(); + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: mockLogger, + requireConfirmation: false, + onBeforeFile, + }); + + await runner.run({ skipConfirmation: true }); + + expect(onBeforeFile).toHaveBeenCalledTimes(2); + expect(onBeforeFile).toHaveBeenCalledWith('01_first.sql', 0, 2); + expect(onBeforeFile).toHaveBeenCalledWith('02_second.sql', 1, 2); + }); + + it('should call onAfterFile callback', async () => { + const onAfterFile = vi.fn(); + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: mockLogger, + requireConfirmation: false, + onAfterFile, + }); + + await runner.run({ skipConfirmation: true }); + + expect(onAfterFile).toHaveBeenCalledTimes(2); + expect(onAfterFile.mock.calls[0][0].fileName).toBe('01_first.sql'); + expect(onAfterFile.mock.calls[1][0].fileName).toBe('02_second.sql'); + }); + + it('should call onComplete callback on success', async () => { + const onComplete = vi.fn(); + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: mockLogger, + requireConfirmation: false, + onComplete, + }); + + await runner.run({ skipConfirmation: true }); + + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete.mock.calls[0][0].allSuccessful).toBe(true); + }); + + it('should track ignored files in summary', async () => { + fs.writeFileSync(path.join(testDir, '_ignored_test.sql'), 'SELECT 1;'); + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: mockLogger, + requireConfirmation: false, + }); + + const summary = await runner.run({ skipConfirmation: true }); + + expect(summary.ignoredFiles).toContain('_ignored_test.sql'); + }); + + it('should include duration in summary', async () => { + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: mockLogger, + requireConfirmation: false, + }); + + const summary = await runner.run({ skipConfirmation: true }); + + expect(summary.totalDurationMs).toBeGreaterThanOrEqual(0); + }); + }); +}); + +describe('runSqlScripts', () => { + let testDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + clearMockInstances(); + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'convenience-test-')); + fs.writeFileSync(path.join(testDir, '01_test.sql'), 'SELECT 1;'); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should be a convenience function that creates runner and executes', async () => { + const summary = await runSqlScripts( + { + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + requireConfirmation: false, + }, + { skipConfirmation: true } + ); + + expect(summary.totalFiles).toBe(1); + }); + + it('should pass options to runner', async () => { + const summary = await runSqlScripts( + { + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + requireConfirmation: false, + }, + { skipConfirmation: true, dryRun: true } + ); + + expect(summary.totalFiles).toBe(0); + expect(summary.committed).toBe(false); + }); +}); + +describe('SqlRunner - configuration', () => { + let testDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + clearMockInstances(); + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'config-test-')); + fs.writeFileSync(path.join(testDir, '01_test.sql'), 'SELECT 1;'); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should use custom filePattern', async () => { + fs.writeFileSync(path.join(testDir, 'custom.psql'), 'SELECT 1;'); + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + requireConfirmation: false, + filePattern: /\.psql$/, + }); + + const summary = await runner.run({ skipConfirmation: true }); + + expect(summary.totalFiles).toBe(1); + expect(summary.results[0].fileName).toBe('custom.psql'); + }); + + it('should use custom ignorePattern', async () => { + fs.writeFileSync(path.join(testDir, 'skip_this.sql'), 'SELECT 1;'); + + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + requireConfirmation: false, + ignorePattern: /^skip_/, + }); + + const summary = await runner.run({ skipConfirmation: true }); + + expect(summary.ignoredFiles).toContain('skip_this.sql'); + }); + + it('should use custom confirmationPhrase', () => { + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + confirmationPhrase: 'CUSTOM_CONFIRM', + }); + + expect(runner).toBeDefined(); + }); + + it('should handle ssl option as boolean', () => { + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + ssl: false, + }); + + expect(runner).toBeDefined(); + }); + + it('should handle ssl option as object', () => { + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + ssl: { rejectUnauthorized: true }, + }); + + expect(runner).toBeDefined(); + }); + + it('should handle null logDirectory', () => { + const runner = new SqlRunner({ + databaseUrl: 'postgres://user:pass@localhost/db', + sqlDirectory: testDir, + logger: new SilentLogger(), + logDirectory: null, + }); + + expect(runner).toBeDefined(); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..55991c7 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,54 @@ +import { vi } from 'vitest'; + +// Store for tracking mock client instances +export const mockClientInstances: ReturnType[] = []; + +// Factory function to create mock pg client +export function createMockPgClient() { + const mockClient = { + connect: vi.fn().mockResolvedValue(undefined), + end: vi.fn().mockResolvedValue(undefined), + query: vi.fn().mockResolvedValue({ rows: [] }), + on: vi.fn(), + removeListener: vi.fn(), + }; + mockClientInstances.push(mockClient); + return mockClient; +} + +// Clear instances between tests +export function clearMockInstances(): void { + mockClientInstances.length = 0; +} + +// Get the last created mock instance +export function getLastMockClient() { + return mockClientInstances[mockClientInstances.length - 1]; +} + +// Global mock for pg module using class wrapped in vi.fn() +vi.mock('pg', () => { + // Create a class that gets registered when instantiated + const MockClientClass = vi.fn().mockImplementation(function(this: ReturnType) { + this.connect = vi.fn().mockResolvedValue(undefined); + this.end = vi.fn().mockResolvedValue(undefined); + this.query = vi.fn().mockResolvedValue({ rows: [] }); + this.on = vi.fn(); + this.removeListener = vi.fn(); + + // Access the array via globalThis + const instances = (globalThis as Record).__mockClientInstances as typeof mockClientInstances; + if (instances) { + instances.push(this); + } + + return this; + }); + + return { + Client: MockClientClass, + }; +}); + +// Make instances globally accessible for the mock +(globalThis as Record).__mockClientInstances = mockClientInstances; diff --git a/tests/ui/components.test.ts b/tests/ui/components.test.ts new file mode 100644 index 0000000..ccc9560 --- /dev/null +++ b/tests/ui/components.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderBanner, renderMinimalBanner } from '../../src/ui/components/banner.js'; +import { renderBox, renderDivider, renderSectionHeader } from '../../src/ui/components/box.js'; +import { renderList, renderKeyValue, renderFileStatus } from '../../src/ui/components/table.js'; +import { renderProgress, renderCountdown } from '../../src/ui/components/spinner.js'; +import { stripAnsi } from '../../src/ui/theme.js'; + +describe('UI Components', () => { + // Ensure colors are enabled for tests + beforeEach(() => { + delete process.env.NO_COLOR; + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + }); + + describe('banner', () => { + describe('renderBanner', () => { + it('should render banner with name and version', () => { + const result = renderBanner({ name: 'test-app', version: '1.0.0' }); + const plain = stripAnsi(result); + + expect(plain).toContain('test-app'); + expect(plain).toContain('v1.0.0'); + }); + + it('should render banner with subtitle', () => { + const result = renderBanner({ + name: 'test-app', + version: '1.0.0', + subtitle: 'My Subtitle', + }); + const plain = stripAnsi(result); + + expect(plain).toContain('My Subtitle'); + }); + + it('should include box drawing characters', () => { + const result = renderBanner({ name: 'test', version: '1.0.0' }); + const plain = stripAnsi(result); + + expect(plain).toContain('╭'); + expect(plain).toContain('╯'); + expect(plain).toContain('│'); + }); + + it('should respect custom width', () => { + const result = renderBanner({ name: 'test', version: '1.0.0', width: 60 }); + const lines = result.split('\n'); + const plainFirstLine = stripAnsi(lines[0]); + + expect(plainFirstLine.length).toBe(60); + }); + }); + + describe('renderMinimalBanner', () => { + it('should render single line banner', () => { + const result = renderMinimalBanner('app', '2.0.0'); + const plain = stripAnsi(result); + + expect(plain).toContain('app'); + expect(plain).toContain('v2.0.0'); + expect(result.split('\n')).toHaveLength(1); + }); + + it('should include arrow symbol', () => { + const result = renderMinimalBanner('app', '1.0.0'); + const plain = stripAnsi(result); + + expect(plain).toContain('▸'); + }); + }); + }); + + describe('box', () => { + describe('renderBox', () => { + it('should render content in a box', () => { + const result = renderBox('Hello World'); + const plain = stripAnsi(result); + + expect(plain).toContain('Hello World'); + expect(plain).toContain('╭'); + expect(plain).toContain('╰'); + }); + + it('should handle array content', () => { + const result = renderBox(['Line 1', 'Line 2']); + const plain = stripAnsi(result); + + expect(plain).toContain('Line 1'); + expect(plain).toContain('Line 2'); + }); + + it('should support sharp style', () => { + const result = renderBox('Content', { style: 'sharp' }); + const plain = stripAnsi(result); + + expect(plain).toContain('┌'); + expect(plain).toContain('└'); + }); + + it('should support double style', () => { + const result = renderBox('Content', { style: 'double' }); + const plain = stripAnsi(result); + + expect(plain).toContain('╔'); + expect(plain).toContain('╚'); + }); + + it('should render with title', () => { + const result = renderBox('Content', { title: 'Title' }); + const plain = stripAnsi(result); + + expect(plain).toContain('Title'); + }); + }); + + describe('renderDivider', () => { + it('should render horizontal line of default width', () => { + const result = renderDivider(); + const plain = stripAnsi(result); + + expect(plain).toHaveLength(40); + expect(plain).toMatch(/^─+$/); + }); + + it('should respect custom width', () => { + const result = renderDivider(20); + const plain = stripAnsi(result); + + expect(plain).toHaveLength(20); + }); + + it('should use custom character', () => { + const result = renderDivider(10, '='); + const plain = stripAnsi(result); + + expect(plain).toBe('=========='); + }); + }); + + describe('renderSectionHeader', () => { + it('should render section header with title', () => { + const result = renderSectionHeader('Section'); + const plain = stripAnsi(result); + + expect(plain).toContain('Section'); + expect(plain).toContain('──'); + }); + }); + }); + + describe('table', () => { + describe('renderList', () => { + it('should render items with bullets', () => { + const result = renderList(['Item 1', 'Item 2', 'Item 3']); + const plain = stripAnsi(result); + + expect(plain).toContain('• Item 1'); + expect(plain).toContain('• Item 2'); + expect(plain).toContain('• Item 3'); + }); + + it('should use custom bullet', () => { + const result = renderList(['Item'], '-'); + const plain = stripAnsi(result); + + expect(plain).toContain('- Item'); + }); + + it('should handle empty array', () => { + const result = renderList([]); + expect(result).toBe(''); + }); + }); + + describe('renderKeyValue', () => { + it('should render key-value pairs aligned', () => { + const result = renderKeyValue([ + { key: 'Name', value: 'Test' }, + { key: 'Version', value: '1.0.0' }, + ]); + const plain = stripAnsi(result); + + expect(plain).toContain('Name'); + expect(plain).toContain('Test'); + expect(plain).toContain('Version'); + expect(plain).toContain('1.0.0'); + }); + + it('should use separator', () => { + const result = renderKeyValue( + [{ key: 'Key', value: 'Value' }], + { separator: ':' } + ); + const plain = stripAnsi(result); + + expect(plain).toContain(':'); + }); + }); + + describe('renderFileStatus', () => { + it('should render pending status', () => { + const result = renderFileStatus([ + { name: 'file.sql', status: 'pending' }, + ]); + const plain = stripAnsi(result); + + expect(plain).toContain('file.sql'); + expect(plain).toContain('○'); + expect(plain).toContain('pending'); + }); + + it('should render running status', () => { + const result = renderFileStatus([ + { name: 'file.sql', status: 'running' }, + ]); + const plain = stripAnsi(result); + + expect(plain).toContain('●'); + expect(plain).toContain('running'); + }); + + it('should render success status with duration', () => { + const result = renderFileStatus([ + { name: 'file.sql', status: 'success', duration: 42 }, + ]); + const plain = stripAnsi(result); + + expect(plain).toContain('✓'); + expect(plain).toContain('42ms'); + }); + + it('should render error status', () => { + const result = renderFileStatus([ + { name: 'file.sql', status: 'error' }, + ]); + const plain = stripAnsi(result); + + expect(plain).toContain('✗'); + expect(plain).toContain('failed'); + }); + + it('should align file names', () => { + const result = renderFileStatus([ + { name: 'short.sql', status: 'success', duration: 10 }, + { name: 'very_long_filename.sql', status: 'success', duration: 20 }, + ]); + const lines = result.split('\n'); + + // Both lines should have same structure with aligned names + expect(lines).toHaveLength(2); + }); + }); + }); + + describe('spinner', () => { + describe('renderProgress', () => { + it('should render active progress', () => { + const result = renderProgress('Loading...'); + const plain = stripAnsi(result); + + expect(plain).toContain('●'); + expect(plain).toContain('Loading...'); + }); + + it('should render done progress', () => { + const result = renderProgress('Complete', 'done'); + const plain = stripAnsi(result); + + expect(plain).toContain('✓'); + expect(plain).toContain('Complete'); + }); + + it('should render error progress', () => { + const result = renderProgress('Failed', 'error'); + const plain = stripAnsi(result); + + expect(plain).toContain('✗'); + expect(plain).toContain('Failed'); + }); + }); + + describe('renderCountdown', () => { + it('should render countdown message', () => { + const result = renderCountdown(30); + const plain = stripAnsi(result); + + expect(plain).toContain('30s'); + expect(plain).toContain('Running in'); + }); + + it('should include reset hint', () => { + const result = renderCountdown(10); + const plain = stripAnsi(result); + + expect(plain).toContain('save again to reset'); + }); + }); + }); +}); diff --git a/tests/ui/renderer.test.ts b/tests/ui/renderer.test.ts new file mode 100644 index 0000000..81b5fbb --- /dev/null +++ b/tests/ui/renderer.test.ts @@ -0,0 +1,411 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Writable } from 'node:stream'; +import { UIRenderer, createUIRenderer } from '../../src/ui/renderer.js'; +import { stripAnsi } from '../../src/ui/theme.js'; + +/** + * Create a mock writable stream that captures output + */ +function createMockStream(): { stream: NodeJS.WriteStream; output: string[] } { + const output: string[] = []; + + const stream = new Writable({ + write(chunk, _encoding, callback) { + output.push(chunk.toString()); + callback(); + }, + }) as NodeJS.WriteStream; + + // Add required TTY methods + stream.isTTY = true; + stream.clearLine = () => true; + stream.cursorTo = () => true; + stream.moveCursor = () => true; + + return { stream, output }; +} + +describe('UIRenderer', () => { + let mockStream: ReturnType; + let ui: UIRenderer; + + beforeEach(() => { + delete process.env.NO_COLOR; + mockStream = createMockStream(); + ui = new UIRenderer({ + name: 'test-app', + version: '1.0.0', + stream: mockStream.stream, + }); + }); + + describe('constructor', () => { + it('should use default values when no options provided', () => { + const renderer = createUIRenderer(); + expect(renderer).toBeInstanceOf(UIRenderer); + }); + + it('should accept custom options', () => { + const renderer = new UIRenderer({ + name: 'custom', + version: '2.0.0', + silent: false, + }); + expect(renderer).toBeInstanceOf(UIRenderer); + }); + }); + + describe('banner', () => { + it('should render banner to stream', () => { + ui.banner(); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('test-app'); + expect(plain).toContain('v1.0.0'); + expect(plain).toContain('Supabase SQL Dev Runner'); + }); + }); + + describe('minimalBanner', () => { + it('should render minimal banner to stream', () => { + ui.minimalBanner(); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('test-app'); + expect(plain).toContain('v1.0.0'); + }); + }); + + describe('devWarning', () => { + it('should render development warning', () => { + ui.devWarning(); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('Development tool'); + expect(plain).toContain('not for production'); + }); + }); + + describe('connectionInfo', () => { + it('should render connection information', () => { + ui.connectionInfo({ + host: 'localhost', + directory: './sql', + fileCount: 5, + logDirectory: './logs', + }); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('localhost'); + expect(plain).toContain('./sql'); + expect(plain).toContain('5 found'); + expect(plain).toContain('./logs'); + }); + + it('should omit logs when logDirectory is null', () => { + ui.connectionInfo({ + host: 'localhost', + directory: './sql', + fileCount: 3, + logDirectory: null, + }); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).not.toContain('Logs'); + }); + }); + + describe('fileList', () => { + it('should render file list with title', () => { + ui.fileList(['file1.sql', 'file2.sql']); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('Files to execute'); + expect(plain).toContain('file1.sql'); + expect(plain).toContain('file2.sql'); + }); + + it('should use custom title', () => { + ui.fileList(['file.sql'], 'Custom Title'); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('Custom Title'); + }); + }); + + describe('ignoredFiles', () => { + it('should render ignored files', () => { + ui.ignoredFiles(['_ignored.sql', 'README.md']); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('Ignoring 2 files'); + expect(plain).toContain('_ignored.sql'); + expect(plain).toContain('README.md'); + }); + + it('should not render anything for empty array', () => { + ui.ignoredFiles([]); + expect(mockStream.output).toHaveLength(0); + }); + + it('should handle singular file', () => { + ui.ignoredFiles(['single.sql']); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('Ignoring 1 file'); + expect(plain).not.toContain('files'); + }); + }); + + describe('dryRun', () => { + it('should render dry run notice', () => { + ui.dryRun(); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('DRY RUN'); + expect(plain).toContain('No changes'); + }); + }); + + describe('confirmationWarning', () => { + it('should render confirmation warning', () => { + ui.confirmationWarning(); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('execute SQL scripts'); + expect(plain).toContain('modify or delete'); + expect(plain).toContain('rollback'); + }); + }); + + describe('fileResultSimple', () => { + it('should render successful file result', () => { + ui.fileResultSimple( + { fileName: 'test.sql', success: true, durationMs: 42 }, + 0, + 3 + ); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('[1/3]'); + expect(plain).toContain('✓'); + expect(plain).toContain('test.sql'); + expect(plain).toContain('42ms'); + }); + + it('should render failed file result', () => { + ui.fileResultSimple( + { fileName: 'test.sql', success: false }, + 1, + 3 + ); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('[2/3]'); + expect(plain).toContain('✗'); + expect(plain).toContain('failed'); + }); + }); + + describe('error', () => { + it('should render error with message', () => { + ui.error({ message: 'Something went wrong' }); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('Error'); + expect(plain).toContain('Something went wrong'); + }); + + it('should render error with code and hint', () => { + ui.error({ + message: 'Table not found', + code: '42P01', + hint: 'Create the table first', + }); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('42P01'); + expect(plain).toContain('Create the table first'); + }); + }); + + describe('summary', () => { + it('should render successful summary', () => { + ui.summary({ + totalFiles: 5, + successfulFiles: 5, + failedFiles: 0, + totalDurationMs: 150, + committed: true, + }); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('5/5 files'); + expect(plain).toContain('150ms'); + expect(plain).toContain('committed'); + expect(plain).toContain('successfully'); + }); + + it('should render failed summary', () => { + ui.summary({ + totalFiles: 5, + successfulFiles: 3, + failedFiles: 2, + totalDurationMs: 100, + committed: false, + }); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('3/5 files'); + expect(plain).toContain('rolled back'); + }); + + it('should render not committed summary', () => { + ui.summary({ + totalFiles: 3, + successfulFiles: 3, + failedFiles: 0, + totalDurationMs: 50, + committed: false, + }); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('not committed'); + }); + }); + + describe('message methods', () => { + it('should render info message', () => { + ui.info('Info message'); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('ℹ'); + expect(plain).toContain('Info message'); + }); + + it('should render success message', () => { + ui.success('Success message'); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('✓'); + expect(plain).toContain('Success message'); + }); + + it('should render warning message', () => { + ui.warning('Warning message'); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('⚠'); + expect(plain).toContain('Warning message'); + }); + + it('should render error message', () => { + ui.errorMessage('Error message'); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('✗'); + expect(plain).toContain('Error message'); + }); + }); + + describe('sqlNotice', () => { + it('should render SQL notice', () => { + ui.sqlNotice('NOTICE: table created'); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('[SQL]'); + expect(plain).toContain('NOTICE: table created'); + }); + }); + + describe('cancelled', () => { + it('should render cancelled message', () => { + ui.cancelled(); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('cancelled'); + }); + }); + + describe('watchMode', () => { + it('should render watch mode started', () => { + ui.watchMode.started(); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('Watching for changes'); + expect(plain).toContain('Ctrl+C'); + }); + + it('should render file changed', () => { + ui.watchMode.fileChanged('test.sql'); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('Changed'); + expect(plain).toContain('test.sql'); + }); + + it('should render stopped', () => { + ui.watchMode.stopped(); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain).toContain('Stopped watching'); + }); + }); + + describe('utility methods', () => { + it('should render newline', () => { + ui.newline(); + expect(mockStream.output).toContain('\n'); + }); + + it('should render divider', () => { + ui.divider(20); + const output = mockStream.output.join(''); + const plain = stripAnsi(output); + + expect(plain.trim()).toMatch(/^─+$/); + }); + }); + + describe('silent mode', () => { + it('should not output anything when silent', () => { + const silentUi = new UIRenderer({ + stream: mockStream.stream, + silent: true, + }); + + silentUi.banner(); + silentUi.info('test'); + silentUi.error({ message: 'error' }); + + expect(mockStream.output).toHaveLength(0); + }); + }); +}); diff --git a/tests/ui/theme.test.ts b/tests/ui/theme.test.ts new file mode 100644 index 0000000..59e4124 --- /dev/null +++ b/tests/ui/theme.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + colors, + theme, + symbols, + spinnerFrames, + supportsColor, + stripAnsi, + visibleLength, + colorize, + c, +} from '../../src/ui/theme.js'; + +describe('UI Theme', () => { + describe('colors', () => { + it('should have reset code', () => { + expect(colors.reset).toBe('\x1b[0m'); + }); + + it('should have all basic colors', () => { + expect(colors.red).toBe('\x1b[31m'); + expect(colors.green).toBe('\x1b[32m'); + expect(colors.yellow).toBe('\x1b[33m'); + expect(colors.blue).toBe('\x1b[34m'); + expect(colors.cyan).toBe('\x1b[36m'); + expect(colors.gray).toBe('\x1b[90m'); + }); + + it('should have modifier codes', () => { + expect(colors.bold).toBe('\x1b[1m'); + expect(colors.dim).toBe('\x1b[2m'); + }); + }); + + describe('theme', () => { + it('should map semantic colors', () => { + expect(theme.success).toBe(colors.green); + expect(theme.error).toBe(colors.red); + expect(theme.warning).toBe(colors.yellow); + expect(theme.info).toBe(colors.cyan); + expect(theme.muted).toBe(colors.gray); + }); + + it('should have UI element colors', () => { + expect(theme.primary).toBe(colors.cyan); + expect(theme.secondary).toBe(colors.gray); + }); + }); + + describe('symbols', () => { + it('should have status indicators', () => { + expect(symbols.success).toBe('✓'); + expect(symbols.error).toBe('✗'); + expect(symbols.warning).toBe('⚠'); + expect(symbols.info).toBe('ℹ'); + expect(symbols.pending).toBe('○'); + expect(symbols.running).toBe('●'); + }); + + it('should have box drawing characters', () => { + expect(symbols.topLeft).toBe('╭'); + expect(symbols.topRight).toBe('╮'); + expect(symbols.bottomLeft).toBe('╰'); + expect(symbols.bottomRight).toBe('╯'); + expect(symbols.horizontal).toBe('─'); + expect(symbols.vertical).toBe('│'); + }); + + it('should have arrow symbols', () => { + expect(symbols.arrow).toBe('→'); + expect(symbols.arrowRight).toBe('▸'); + expect(symbols.bullet).toBe('•'); + }); + }); + + describe('spinnerFrames', () => { + it('should have spinner animation frames', () => { + expect(spinnerFrames).toHaveLength(10); + expect(spinnerFrames[0]).toBe('⠋'); + }); + }); + + describe('supportsColor', () => { + let originalNoColor: string | undefined; + let originalForceColor: string | undefined; + let originalIsTTY: boolean | undefined; + + beforeEach(() => { + originalNoColor = process.env.NO_COLOR; + originalForceColor = process.env.FORCE_COLOR; + originalIsTTY = process.stdout.isTTY; + delete process.env.NO_COLOR; + delete process.env.FORCE_COLOR; + }); + + afterEach(() => { + if (originalNoColor !== undefined) { + process.env.NO_COLOR = originalNoColor; + } else { + delete process.env.NO_COLOR; + } + if (originalForceColor !== undefined) { + process.env.FORCE_COLOR = originalForceColor; + } else { + delete process.env.FORCE_COLOR; + } + Object.defineProperty(process.stdout, 'isTTY', { + value: originalIsTTY, + writable: true, + }); + }); + + it('should return false when NO_COLOR is set', () => { + process.env.NO_COLOR = '1'; + expect(supportsColor()).toBe(false); + }); + + it('should return true when FORCE_COLOR is set', () => { + process.env.FORCE_COLOR = '1'; + expect(supportsColor()).toBe(true); + }); + + it('should return true when stdout is TTY', () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + expect(supportsColor()).toBe(true); + }); + + it('should return false when stdout is not TTY and no env vars', () => { + Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true }); + expect(supportsColor()).toBe(false); + }); + }); + + describe('stripAnsi', () => { + it('should remove ANSI color codes', () => { + const colored = '\x1b[31mred text\x1b[0m'; + expect(stripAnsi(colored)).toBe('red text'); + }); + + it('should handle multiple color codes', () => { + const colored = '\x1b[1m\x1b[32mbold green\x1b[0m normal'; + expect(stripAnsi(colored)).toBe('bold green normal'); + }); + + it('should return unchanged string if no ANSI codes', () => { + const plain = 'plain text'; + expect(stripAnsi(plain)).toBe('plain text'); + }); + + it('should handle empty string', () => { + expect(stripAnsi('')).toBe(''); + }); + }); + + describe('visibleLength', () => { + it('should return correct length without ANSI codes', () => { + const colored = '\x1b[31mhello\x1b[0m'; + expect(visibleLength(colored)).toBe(5); + }); + + it('should return correct length for plain text', () => { + expect(visibleLength('hello')).toBe(5); + }); + + it('should handle unicode symbols', () => { + expect(visibleLength('✓ success')).toBe(9); + }); + }); + + describe('colorize', () => { + let originalNoColor: string | undefined; + + beforeEach(() => { + originalNoColor = process.env.NO_COLOR; + delete process.env.NO_COLOR; + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + }); + + afterEach(() => { + if (originalNoColor !== undefined) { + process.env.NO_COLOR = originalNoColor; + } else { + delete process.env.NO_COLOR; + } + }); + + it('should wrap text with color codes when colors supported', () => { + const result = colorize('test', colors.red); + expect(result).toBe('\x1b[31mtest\x1b[0m'); + }); + + it('should return plain text when NO_COLOR is set', () => { + process.env.NO_COLOR = '1'; + const result = colorize('test', colors.red); + expect(result).toBe('test'); + }); + }); + + describe('c (color shortcuts)', () => { + let originalNoColor: string | undefined; + + beforeEach(() => { + originalNoColor = process.env.NO_COLOR; + delete process.env.NO_COLOR; + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + }); + + afterEach(() => { + if (originalNoColor !== undefined) { + process.env.NO_COLOR = originalNoColor; + } else { + delete process.env.NO_COLOR; + } + }); + + it('should have status color functions', () => { + expect(c.success('ok')).toContain('ok'); + expect(c.error('fail')).toContain('fail'); + expect(c.warning('warn')).toContain('warn'); + expect(c.info('info')).toContain('info'); + expect(c.muted('muted')).toContain('muted'); + }); + + it('should have UI color functions', () => { + expect(c.primary('primary')).toContain('primary'); + expect(c.secondary('secondary')).toContain('secondary'); + expect(c.accent('accent')).toContain('accent'); + }); + + it('should have text style functions', () => { + expect(c.title('title')).toContain('title'); + expect(c.subtitle('subtitle')).toContain('subtitle'); + expect(c.label('label')).toContain('label'); + }); + + it('should have raw color functions', () => { + expect(c.bold('bold')).toContain('bold'); + expect(c.dim('dim')).toContain('dim'); + expect(c.green('green')).toContain('green'); + expect(c.red('red')).toContain('red'); + expect(c.cyan('cyan')).toContain('cyan'); + }); + + it('should apply ANSI codes when colors supported', () => { + const result = c.success('ok'); + expect(result).toContain('\x1b['); + }); + }); +}); diff --git a/tests/watcher.test.ts b/tests/watcher.test.ts new file mode 100644 index 0000000..65751aa --- /dev/null +++ b/tests/watcher.test.ts @@ -0,0 +1,373 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import { startWatcher, type WatchOptions } from '../src/core/watcher.js'; + +// Mock fs.watch +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + watch: vi.fn(), + }; +}); + +// Mock UI module +vi.mock('../src/ui/index.js', () => ({ + c: { + muted: (s: string) => s, + primary: (s: string) => s, + cyan: (s: string) => s, + }, + symbols: { + pending: '⏳', + arrowRight: '▸', + running: '👀', + }, +})); + +describe('Watcher Module', () => { + let mockWatcher: { + on: ReturnType; + close: ReturnType; + listeners: Record void)[]>; + }; + let watchCallback: ((eventType: string, filename: string | null) => void) | null; + let mockLogger: { info: ReturnType; warning: ReturnType }; + let stdoutWriteSpy: ReturnType; + let _consoleLogSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + watchCallback = null; + + // Create mock watcher + mockWatcher = { + listeners: {}, + on: vi.fn((event: string, handler: (err: Error) => void) => { + if (!mockWatcher.listeners[event]) { + mockWatcher.listeners[event] = []; + } + mockWatcher.listeners[event].push(handler); + }), + close: vi.fn(), + }; + + // Mock fs.watch to capture callback and return mock watcher + vi.mocked(fs.watch).mockImplementation((_path, callback) => { + watchCallback = callback as (eventType: string, filename: string | null) => void; + return mockWatcher as unknown as fs.FSWatcher; + }); + + // Mock logger + mockLogger = { + info: vi.fn(), + warning: vi.fn(), + }; + + // Spy on stdout.write and console.log + stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + _consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + function createWatchOptions(overrides: Partial = {}): WatchOptions { + return { + directory: './sql', + pattern: /\.sql$/, + countdownSeconds: 30, + onExecute: vi.fn().mockResolvedValue(undefined), + logger: mockLogger, + ...overrides, + }; + } + + describe('startWatcher', () => { + it('should start watching the specified directory', () => { + const options = createWatchOptions(); + startWatcher(options); + + expect(fs.watch).toHaveBeenCalledWith('./sql', expect.any(Function)); + }); + + it('should return a cleanup function', () => { + const options = createWatchOptions(); + const cleanup = startWatcher(options); + + expect(typeof cleanup).toBe('function'); + }); + + it('should close watcher when cleanup is called', () => { + const options = createWatchOptions(); + const cleanup = startWatcher(options); + + cleanup(); + + expect(mockWatcher.close).toHaveBeenCalled(); + }); + + it('should register error handler on watcher', () => { + const options = createWatchOptions(); + startWatcher(options); + + expect(mockWatcher.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + }); + + describe('file change detection', () => { + it('should react to .sql file changes', () => { + const options = createWatchOptions(); + startWatcher(options); + + // Simulate file change + watchCallback?.('change', 'test.sql'); + + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('test.sql')); + }); + + it('should ignore non-sql file changes', () => { + const options = createWatchOptions(); + startWatcher(options); + + // Simulate non-SQL file change + watchCallback?.('change', 'test.txt'); + + expect(mockLogger.info).not.toHaveBeenCalled(); + }); + + it('should ignore null filename', () => { + const options = createWatchOptions(); + startWatcher(options); + + // Simulate change with null filename (can happen on some platforms) + watchCallback?.('change', null); + + expect(mockLogger.info).not.toHaveBeenCalled(); + }); + + it('should use custom pattern for file matching', () => { + const options = createWatchOptions({ pattern: /\.psql$/ }); + startWatcher(options); + + // SQL should not match + watchCallback?.('change', 'test.sql'); + expect(mockLogger.info).not.toHaveBeenCalled(); + + // PSQL should match + watchCallback?.('change', 'test.psql'); + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('test.psql')); + }); + }); + + describe('countdown timer', () => { + it('should show countdown message after file change', () => { + const options = createWatchOptions({ countdownSeconds: 5 }); + startWatcher(options); + + watchCallback?.('change', 'test.sql'); + + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('Running in 5s') + ); + }); + + it('should decrement countdown every second', () => { + const options = createWatchOptions({ countdownSeconds: 3 }); + startWatcher(options); + + watchCallback?.('change', 'test.sql'); + + // Initial countdown + expect(stdoutWriteSpy).toHaveBeenCalledWith(expect.stringContaining('3s')); + + // After 1 second + vi.advanceTimersByTime(1000); + expect(stdoutWriteSpy).toHaveBeenCalledWith(expect.stringContaining('2s')); + + // After 2 seconds + vi.advanceTimersByTime(1000); + expect(stdoutWriteSpy).toHaveBeenCalledWith(expect.stringContaining('1s')); + }); + + it('should execute after countdown completes', async () => { + const onExecute = vi.fn().mockResolvedValue(undefined); + const options = createWatchOptions({ countdownSeconds: 2, onExecute }); + startWatcher(options); + + watchCallback?.('change', 'test.sql'); + + // Before countdown completes + expect(onExecute).not.toHaveBeenCalled(); + + // After countdown completes + await vi.advanceTimersByTimeAsync(2000); + + expect(onExecute).toHaveBeenCalled(); + }); + + it('should reset countdown on new file change', async () => { + const onExecute = vi.fn().mockResolvedValue(undefined); + const options = createWatchOptions({ countdownSeconds: 3, onExecute }); + startWatcher(options); + + // First change + watchCallback?.('change', 'first.sql'); + + // Wait 2 seconds (before execution) + vi.advanceTimersByTime(2000); + + // Second change - should reset timer + watchCallback?.('change', 'second.sql'); + + // Wait another 2 seconds (total 4 from first change, but only 2 from reset) + vi.advanceTimersByTime(2000); + + // Should not have executed yet (reset to 3s, only 2s passed) + expect(onExecute).not.toHaveBeenCalled(); + + // Wait 1 more second + await vi.advanceTimersByTimeAsync(1000); + + // Now should have executed + expect(onExecute).toHaveBeenCalledTimes(1); + }); + + it('should show watching message after execution', async () => { + const options = createWatchOptions({ countdownSeconds: 1 }); + startWatcher(options); + + watchCallback?.('change', 'test.sql'); + await vi.advanceTimersByTimeAsync(1000); + + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('Watching for changes') + ); + }); + }); + + describe('execution guard', () => { + it('should not start new countdown while executing', async () => { + let resolveExecution: () => void; + const executionPromise = new Promise((resolve) => { + resolveExecution = resolve; + }); + const onExecute = vi.fn().mockReturnValue(executionPromise); + const options = createWatchOptions({ countdownSeconds: 1, onExecute }); + startWatcher(options); + + // Trigger first execution + watchCallback?.('change', 'first.sql'); + await vi.advanceTimersByTimeAsync(1000); + + // Execution started + expect(onExecute).toHaveBeenCalledTimes(1); + + // Try to trigger another change while executing + watchCallback?.('change', 'second.sql'); + + // Should log that execution is in progress + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('Execution in progress') + ); + + // Complete the execution + resolveExecution!(); + await vi.advanceTimersByTimeAsync(0); + }); + + it('should allow new countdown after execution completes', async () => { + const onExecute = vi.fn().mockResolvedValue(undefined); + const options = createWatchOptions({ countdownSeconds: 1, onExecute }); + startWatcher(options); + + // First execution + watchCallback?.('change', 'first.sql'); + await vi.advanceTimersByTimeAsync(1000); + expect(onExecute).toHaveBeenCalledTimes(1); + + // Second change after execution completes + watchCallback?.('change', 'second.sql'); + await vi.advanceTimersByTimeAsync(1000); + + expect(onExecute).toHaveBeenCalledTimes(2); + }); + }); + + describe('error handling', () => { + it('should handle execution errors gracefully', async () => { + const onExecute = vi.fn().mockRejectedValue(new Error('Execution failed')); + const options = createWatchOptions({ countdownSeconds: 1, onExecute }); + startWatcher(options); + + watchCallback?.('change', 'test.sql'); + await vi.advanceTimersByTimeAsync(1000); + + expect(mockLogger.warning).toHaveBeenCalledWith( + expect.stringContaining('Execution error') + ); + }); + + it('should continue watching after execution error', async () => { + const onExecute = vi.fn() + .mockRejectedValueOnce(new Error('First failed')) + .mockResolvedValueOnce(undefined); + const options = createWatchOptions({ countdownSeconds: 1, onExecute }); + startWatcher(options); + + // First execution (fails) + watchCallback?.('change', 'test.sql'); + await vi.advanceTimersByTimeAsync(1000); + + // Second execution (succeeds) + watchCallback?.('change', 'test.sql'); + await vi.advanceTimersByTimeAsync(1000); + + expect(onExecute).toHaveBeenCalledTimes(2); + }); + + it('should handle watcher errors', () => { + const options = createWatchOptions(); + startWatcher(options); + + // Trigger watcher error + const errorHandler = mockWatcher.listeners['error']?.[0]; + errorHandler?.(new Error('Watch error')); + + expect(mockLogger.warning).toHaveBeenCalledWith( + expect.stringContaining('Watch error') + ); + }); + }); + + describe('cleanup', () => { + it('should clear countdown timers on cleanup', async () => { + const onExecute = vi.fn().mockResolvedValue(undefined); + const options = createWatchOptions({ countdownSeconds: 5, onExecute }); + const cleanup = startWatcher(options); + + // Start countdown + watchCallback?.('change', 'test.sql'); + + // Cleanup before countdown completes + cleanup(); + + // Advance time past countdown + await vi.advanceTimersByTimeAsync(10000); + + // Execute should not have been called + expect(onExecute).not.toHaveBeenCalled(); + }); + + it('should close watcher on cleanup', () => { + const options = createWatchOptions(); + const cleanup = startWatcher(options); + + cleanup(); + + expect(mockWatcher.close).toHaveBeenCalled(); + }); + }); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..3d468ff --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "node", + "sourceMap": true + }, + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 0000000..a63a2ff --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "./dist/cjs", + "declaration": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/cli/**", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/tsconfig.cli.json b/tsconfig.cli.json new file mode 100644 index 0000000..c1676f9 --- /dev/null +++ b/tsconfig.cli.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2022", + "outDir": "./dist/cli", + "declaration": false + }, + "include": ["src/cli/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 0000000..7606261 --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2022", + "outDir": "./dist/esm", + "declaration": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/cli/**", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a6bc32f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2022", + "outDir": "./dist", + "declaration": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/tsconfig.types.json b/tsconfig.types.json new file mode 100644 index 0000000..03fed78 --- /dev/null +++ b/tsconfig.types.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2022", + "outDir": "./dist/types", + "declaration": true, + "emitDeclarationOnly": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/cli/**", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..f5d23a0 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + setupFiles: ['./tests/setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/cli/**', 'src/**/*.d.ts'], + }, + }, +});