From b48e57879c46c5069a87878a1c3d932771a9e375 Mon Sep 17 00:00:00 2001 From: Ariel Marti Date: Thu, 11 Dec 2025 00:43:19 +0700 Subject: [PATCH 1/3] Init --- .editorconfig | 21 + .github/workflows/ci.yml | 137 + .gitignore | 70 + .npmignore | 31 + .nvmrc | 1 + .prettierrc | 10 + CHANGELOG.md | 31 + LICENSE | 21 + README.md | 385 ++ eslint.config.js | 101 + package-lock.json | 3287 +++++++++++++++++ package.json | 89 + scripts/copy-types.js | 51 + scripts/fix-cjs.js | 56 + scripts/sync-version.js | 63 + src/cli/application.ts | 185 + src/cli/arg-parser.ts | 209 ++ src/cli/cli.ts | 13 + src/cli/config-loader.ts | 194 + src/cli/config-merger.ts | 172 + src/cli/config-schema.ts | 145 + src/cli/env-loader.ts | 150 + src/cli/executor.ts | 190 + src/cli/help.ts | 189 + src/cli/index.ts | 85 + src/cli/types.ts | 90 + src/cli/validators.ts | 162 + src/core/connection.ts | 140 + .../detectors/authentication-detector.ts | 92 + src/core/errors/detectors/base-detector.ts | 105 + .../detectors/connection-refused-detector.ts | 75 + .../detectors/connection-timeout-detector.ts | 79 + .../detectors/database-not-found-detector.ts | 61 + .../dns-direct-connection-detector.ts | 63 + .../errors/detectors/dns-generic-detector.ts | 59 + src/core/errors/detectors/index.ts | 15 + .../errors/detectors/invalid-url-detector.ts | 76 + .../detectors/prepared-statement-detector.ts | 65 + src/core/errors/detectors/ssl-detector.ts | 90 + .../too-many-connections-detector.ts | 84 + src/core/errors/error-handler.ts | 175 + src/core/errors/formatters.ts | 169 + src/core/errors/index.ts | 72 + src/core/errors/registry.ts | 120 + src/core/errors/types.ts | 98 + src/core/executor.ts | 320 ++ src/core/file-scanner.ts | 126 + src/core/logger.ts | 151 + src/core/runner.ts | 458 +++ src/core/watcher.ts | 134 + src/index.ts | 81 + src/types.ts | 236 ++ src/ui/components/banner.ts | 90 + src/ui/components/box.ts | 162 + src/ui/components/spinner.ts | 164 + src/ui/components/table.ts | 226 ++ src/ui/index.ts | 71 + src/ui/renderer.ts | 396 ++ src/ui/theme.ts | 209 ++ tests/cli.test.ts | 737 ++++ tests/config.test.ts | 420 +++ tests/connection.test.ts | 234 ++ tests/executor.test.ts | 326 ++ tests/file-scanner.extended.test.ts | 295 ++ tests/file-scanner.test.ts | 120 + tests/integration.test.ts | 317 ++ tests/logger.test.ts | 261 ++ tests/runner.test.ts | 390 ++ tests/setup.ts | 54 + tests/ui/components.test.ts | 299 ++ tests/ui/renderer.test.ts | 411 +++ tests/ui/theme.test.ts | 249 ++ tsconfig.base.json | 19 + tsconfig.cjs.json | 10 + tsconfig.cli.json | 10 + tsconfig.esm.json | 10 + tsconfig.json | 10 + tsconfig.types.json | 11 + vitest.config.ts | 16 + 79 files changed, 14799 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 .nvmrc create mode 100644 .prettierrc create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 eslint.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/copy-types.js create mode 100644 scripts/fix-cjs.js create mode 100644 scripts/sync-version.js create mode 100644 src/cli/application.ts create mode 100644 src/cli/arg-parser.ts create mode 100644 src/cli/cli.ts create mode 100644 src/cli/config-loader.ts create mode 100644 src/cli/config-merger.ts create mode 100644 src/cli/config-schema.ts create mode 100644 src/cli/env-loader.ts create mode 100644 src/cli/executor.ts create mode 100644 src/cli/help.ts create mode 100644 src/cli/index.ts create mode 100644 src/cli/types.ts create mode 100644 src/cli/validators.ts create mode 100644 src/core/connection.ts create mode 100644 src/core/errors/detectors/authentication-detector.ts create mode 100644 src/core/errors/detectors/base-detector.ts create mode 100644 src/core/errors/detectors/connection-refused-detector.ts create mode 100644 src/core/errors/detectors/connection-timeout-detector.ts create mode 100644 src/core/errors/detectors/database-not-found-detector.ts create mode 100644 src/core/errors/detectors/dns-direct-connection-detector.ts create mode 100644 src/core/errors/detectors/dns-generic-detector.ts create mode 100644 src/core/errors/detectors/index.ts create mode 100644 src/core/errors/detectors/invalid-url-detector.ts create mode 100644 src/core/errors/detectors/prepared-statement-detector.ts create mode 100644 src/core/errors/detectors/ssl-detector.ts create mode 100644 src/core/errors/detectors/too-many-connections-detector.ts create mode 100644 src/core/errors/error-handler.ts create mode 100644 src/core/errors/formatters.ts create mode 100644 src/core/errors/index.ts create mode 100644 src/core/errors/registry.ts create mode 100644 src/core/errors/types.ts create mode 100644 src/core/executor.ts create mode 100644 src/core/file-scanner.ts create mode 100644 src/core/logger.ts create mode 100644 src/core/runner.ts create mode 100644 src/core/watcher.ts create mode 100644 src/index.ts create mode 100644 src/types.ts create mode 100644 src/ui/components/banner.ts create mode 100644 src/ui/components/box.ts create mode 100644 src/ui/components/spinner.ts create mode 100644 src/ui/components/table.ts create mode 100644 src/ui/index.ts create mode 100644 src/ui/renderer.ts create mode 100644 src/ui/theme.ts create mode 100644 tests/cli.test.ts create mode 100644 tests/config.test.ts create mode 100644 tests/connection.test.ts create mode 100644 tests/executor.test.ts create mode 100644 tests/file-scanner.extended.test.ts create mode 100644 tests/file-scanner.test.ts create mode 100644 tests/integration.test.ts create mode 100644 tests/logger.test.ts create mode 100644 tests/runner.test.ts create mode 100644 tests/setup.ts create mode 100644 tests/ui/components.test.ts create mode 100644 tests/ui/renderer.test.ts create mode 100644 tests/ui/theme.test.ts create mode 100644 tsconfig.base.json create mode 100644 tsconfig.cjs.json create mode 100644 tsconfig.cli.json create mode 100644 tsconfig.esm.json create mode 100644 tsconfig.json create mode 100644 tsconfig.types.json create mode 100644 vitest.config.ts 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..d07a10f --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,101 @@ +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', + }, + }, + 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', + // 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..1ad445c --- /dev/null +++ b/src/cli/application.ts @@ -0,0 +1,185 @@ +/** + * 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); + + // 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); + } +} + +/** + * 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..eb312de --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,85 @@ +/** + * 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, +} 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..dc36f53 --- /dev/null +++ b/src/cli/types.ts @@ -0,0 +1,90 @@ +/** + * 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; +} + +/** + * 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..610370e --- /dev/null +++ b/src/cli/validators.ts @@ -0,0 +1,162 @@ +/** + * 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 { 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 }; + } +} + +/** + * Composite validator for all CLI inputs + */ +export class CliValidator { + private databaseUrlValidator = new DatabaseUrlValidator(); + private sqlDirectoryValidator: SqlDirectoryValidator; + private envFileValidator: EnvFileValidator; + + 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); + } +} + 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..4d65289 --- /dev/null +++ b/src/core/logger.ts @@ -0,0 +1,151 @@ +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) { + this.ensureLogDirectory(); + this.logFile = path.join(this.logDirectory, 'sql-runner.log'); + this.errorFile = path.join(this.logDirectory, 'sql-runner-error.log'); + } + } + + /** Creates the log directory if it doesn't exist */ + private ensureLogDirectory(): void { + if (this.logDirectory && !fs.existsSync(this.logDirectory)) { + fs.mkdirSync(this.logDirectory, { recursive: true }); + } + } + + /** 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 { + if (this.logFile) { + fs.appendFileSync(this.logFile, `${message}\n`); + } + + if (isError && this.errorFile) { + fs.appendFileSync(this.errorFile, `${message}\n`); + } + } + + 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..a33f700 --- /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..8b6071c --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,737 @@ +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 { + 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); + }); + }); +}); + +// ============================================================================= +// 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..0995e09 --- /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, mkdirSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { ConfigLoader } from '../src/cli/config-loader.js'; +import { ConfigMerger, type MergedConfig } 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..26d2aa6 --- /dev/null +++ b/tests/logger.test.ts @@ -0,0 +1,261 @@ +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]'); + }); + }); +}); + +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..475b54f --- /dev/null +++ b/tests/ui/components.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, beforeEach, afterEach } 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/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'], + }, + }, +}); From be5cfe704d79d4e4ed6de52939bc7f277a88c39f Mon Sep 17 00:00:00 2001 From: Ariel Marti Date: Thu, 11 Dec 2025 00:51:55 +0700 Subject: [PATCH 2/3] Add argument combination validation to CLI Introduces ArgumentCombinationValidator to check for conflicting or unusual CLI argument combinations, with error and warning handling in CliApplication. Updates types and validators to support this feature. Also adds comprehensive tests for watcher module and minor test cleanup. --- eslint.config.js | 4 + src/cli/application.ts | 22 +++ src/cli/index.ts | 1 + src/cli/types.ts | 1 + src/cli/validators.ts | 47 ++++- src/core/runner.ts | 2 +- tests/cli.test.ts | 2 +- tests/config.test.ts | 4 +- tests/ui/components.test.ts | 2 +- tests/watcher.test.ts | 373 ++++++++++++++++++++++++++++++++++++ 10 files changed, 452 insertions(+), 6 deletions(-) create mode 100644 tests/watcher.test.ts diff --git a/eslint.config.js b/eslint.config.js index d07a10f..0a49be1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -28,6 +28,8 @@ export default [ // Web APIs available in Node.js URL: 'readonly', URLSearchParams: 'readonly', + // Node.js namespace for types + NodeJS: 'readonly', }, }, plugins: { @@ -72,6 +74,8 @@ export default [ clearInterval: 'readonly', URL: 'readonly', URLSearchParams: 'readonly', + // Node.js namespace for types + NodeJS: 'readonly', // Vitest globals describe: 'readonly', it: 'readonly', diff --git a/src/cli/application.ts b/src/cli/application.ts index 1ad445c..c5fec83 100644 --- a/src/cli/application.ts +++ b/src/cli/application.ts @@ -96,6 +96,9 @@ export class CliApplication { // 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}`); @@ -174,6 +177,25 @@ export class CliApplication { 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}`); + } + } + } } /** diff --git a/src/cli/index.ts b/src/cli/index.ts index eb312de..d37081a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -35,6 +35,7 @@ export { DatabaseUrlValidator, SqlDirectoryValidator, EnvFileValidator, + ArgumentCombinationValidator, } from './validators.js'; export type { Validator } from './validators.js'; diff --git a/src/cli/types.ts b/src/cli/types.ts index dc36f53..a18968a 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -78,6 +78,7 @@ export interface ExitHandler { export interface ValidationResult { valid: boolean; error?: string; + warnings?: string[]; } /** diff --git a/src/cli/validators.ts b/src/cli/validators.ts index 610370e..b178cc4 100644 --- a/src/cli/validators.ts +++ b/src/cli/validators.ts @@ -7,7 +7,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import type { FileSystem, ValidationResult } from './types.js'; +import type { CliArgs, FileSystem, ValidationResult } from './types.js'; import { defaultFileSystem } from './env-loader.js'; /** @@ -118,6 +118,43 @@ export class EnvFileValidator implements Validator { } } +/** + * 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 */ @@ -125,6 +162,7 @@ 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); @@ -158,5 +196,12 @@ export class CliValidator { 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/runner.ts b/src/core/runner.ts index a33f700..bab4363 100644 --- a/src/core/runner.ts +++ b/src/core/runner.ts @@ -114,7 +114,7 @@ export class SqlRunner { // Extract host from masked URL const maskedUrl = maskPassword(this.config.databaseUrl); - const hostMatch = maskedUrl.match(/@([^:\/]+)/); + const hostMatch = maskedUrl.match(/@([^:/]+)/); const host = hostMatch ? hostMatch[1] : maskedUrl; // Scan for SQL files first to get count diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 8b6071c..aef34e5 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +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'; diff --git a/tests/config.test.ts b/tests/config.test.ts index 0995e09..5fadbcd 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -5,11 +5,11 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs'; +import { mkdtempSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { ConfigLoader } from '../src/cli/config-loader.js'; -import { ConfigMerger, type MergedConfig } from '../src/cli/config-merger.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'; diff --git a/tests/ui/components.test.ts b/tests/ui/components.test.ts index 475b54f..ccc9560 100644 --- a/tests/ui/components.test.ts +++ b/tests/ui/components.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +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'; 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(); + }); + }); +}); From 40cd336e264ddec8cea9415272bb5811f19f622f Mon Sep 17 00:00:00 2001 From: Ariel Marti Date: Thu, 11 Dec 2025 00:56:23 +0700 Subject: [PATCH 3/3] Handle logger file and directory errors gracefully Improves ConsoleLogger to disable file logging if log directory creation fails and to silently ignore file write errors. Adds tests to verify logger behavior when log directory cannot be created or is deleted during execution. --- src/core/logger.ts | 40 ++++++++++++++++++++--------- tests/cli.test.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++ tests/logger.test.ts | 28 +++++++++++++++++++++ 3 files changed, 116 insertions(+), 12 deletions(-) diff --git a/src/core/logger.ts b/src/core/logger.ts index 4d65289..ced5624 100644 --- a/src/core/logger.ts +++ b/src/core/logger.ts @@ -40,16 +40,27 @@ export class ConsoleLogger implements Logger { this.logDirectory = options.logDirectory ?? null; if (this.logDirectory) { - this.ensureLogDirectory(); - this.logFile = path.join(this.logDirectory, 'sql-runner.log'); - this.errorFile = path.join(this.logDirectory, 'sql-runner-error.log'); + 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(): void { - if (this.logDirectory && !fs.existsSync(this.logDirectory)) { - fs.mkdirSync(this.logDirectory, { recursive: true }); + 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; } } @@ -60,12 +71,17 @@ export class ConsoleLogger implements Logger { /** Appends a message to log files (main log and optionally error log) */ private writeToFile(message: string, isError = false): void { - if (this.logFile) { - fs.appendFileSync(this.logFile, `${message}\n`); - } - - if (isError && this.errorFile) { - fs.appendFileSync(this.errorFile, `${message}\n`); + 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 } } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index aef34e5..226e833 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -532,6 +532,66 @@ describe('Validators', () => { // 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(); + }); + }); }); }); diff --git a/tests/logger.test.ts b/tests/logger.test.ts index 26d2aa6..9750af9 100644 --- a/tests/logger.test.ts +++ b/tests/logger.test.ts @@ -183,6 +183,34 @@ describe('ConsoleLogger', () => { 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(); + }); }); });