From bd3e9064a3d43588844c228eed27588cbc4a30c7 Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:24:14 +0200 Subject: [PATCH] feat: complete Simple DB 0.1.1 in JavaScript --- .github/workflows/ci.yml | 6 +- .vscode/launch.json | 5 +- .vscode/tasks.json | 11 +- .vscodeignore | 22 +- CHANGELOG.md | 39 +- README.md | 183 ++- eslint.config.mjs | 36 +- package-lock.json | 1911 ++++++++++++++++++++------ package.json | 409 +++++- plan.txt | 520 +------ project requirements.txt | 92 +- src/adapters/baseAdapter.js | 121 ++ src/adapters/factory.js | 28 + src/adapters/mysqlAdapter.js | 498 +++++++ src/adapters/oracleAdapter.js | 462 +++++++ src/adapters/postgresqlAdapter.js | 661 +++++++++ src/adapters/sqlServerAdapter.js | 756 ++++++++++ src/adapters/sqliteAdapter.js | 341 +++++ src/adapters/sqliteWorker.js | 456 ++++++ src/core/errors.js | 44 + src/core/valueNormalizer.js | 67 + src/databaseEngines.js | 87 ++ src/databaseEngines.ts | 34 - src/extension.js | 650 +++++++++ src/extension.ts | 30 - src/managers/connectionManager.js | 260 ++++ src/managers/editorSessionManager.js | 198 +++ src/services/exportService.js | 149 ++ src/services/queryRunner.js | 395 ++++++ src/sql/ddlTemplates.js | 320 +++++ src/sql/safety.js | 36 + src/sql/sqlSplitter.js | 433 ++++++ src/sql/transactionControl.js | 45 + src/storage/connectionStore.js | 157 +++ src/storage/historyStore.js | 88 ++ src/storage/resultStore.js | 245 ++++ src/test/adapterFactory.test.js | 41 + src/test/connectionStore.test.js | 77 ++ src/test/databaseEngines.test.js | 38 + src/test/databaseEngines.test.ts | 25 - src/test/ddlTemplates.test.js | 106 ++ src/test/resultStore.test.js | 60 + src/test/safety.test.js | 27 + src/test/sqlSplitter.test.js | 124 ++ src/test/sqliteAdapter.test.js | 180 +++ src/test/transactionControl.test.js | 30 + src/test/valueNormalizer.test.js | 30 + src/test/xlsx.test.js | 30 + src/ui/connectionForm.js | 381 +++++ src/views/connectionsTreeProvider.js | 350 +++++ src/views/connectionsTreeProvider.ts | 87 -- src/views/historyTreeProvider.js | 78 ++ src/views/resultPanel.js | 269 ++++ tsconfig.json | 24 - 54 files changed, 10561 insertions(+), 1191 deletions(-) create mode 100644 src/adapters/baseAdapter.js create mode 100644 src/adapters/factory.js create mode 100644 src/adapters/mysqlAdapter.js create mode 100644 src/adapters/oracleAdapter.js create mode 100644 src/adapters/postgresqlAdapter.js create mode 100644 src/adapters/sqlServerAdapter.js create mode 100644 src/adapters/sqliteAdapter.js create mode 100644 src/adapters/sqliteWorker.js create mode 100644 src/core/errors.js create mode 100644 src/core/valueNormalizer.js create mode 100644 src/databaseEngines.js delete mode 100644 src/databaseEngines.ts create mode 100644 src/extension.js delete mode 100644 src/extension.ts create mode 100644 src/managers/connectionManager.js create mode 100644 src/managers/editorSessionManager.js create mode 100644 src/services/exportService.js create mode 100644 src/services/queryRunner.js create mode 100644 src/sql/ddlTemplates.js create mode 100644 src/sql/safety.js create mode 100644 src/sql/sqlSplitter.js create mode 100644 src/sql/transactionControl.js create mode 100644 src/storage/connectionStore.js create mode 100644 src/storage/historyStore.js create mode 100644 src/storage/resultStore.js create mode 100644 src/test/adapterFactory.test.js create mode 100644 src/test/connectionStore.test.js create mode 100644 src/test/databaseEngines.test.js delete mode 100644 src/test/databaseEngines.test.ts create mode 100644 src/test/ddlTemplates.test.js create mode 100644 src/test/resultStore.test.js create mode 100644 src/test/safety.test.js create mode 100644 src/test/sqlSplitter.test.js create mode 100644 src/test/sqliteAdapter.test.js create mode 100644 src/test/transactionControl.test.js create mode 100644 src/test/valueNormalizer.test.js create mode 100644 src/test/xlsx.test.js create mode 100644 src/ui/connectionForm.js create mode 100644 src/views/connectionsTreeProvider.js delete mode 100644 src/views/connectionsTreeProvider.ts create mode 100644 src/views/historyTreeProvider.js create mode 100644 src/views/resultPanel.js delete mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 393a82b..d9b8d19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "version-*" pull_request: permissions: @@ -11,7 +12,7 @@ permissions: jobs: verify: - name: Compilar, analizar, probar y empaquetar + name: Analizar, probar y empaquetar JavaScript runs-on: ubuntu-latest steps: @@ -27,6 +28,9 @@ jobs: - name: Instalar dependencias run: npm ci + - name: Auditar dependencias de producción + run: npm audit --omit=dev + - name: Verificar el proyecto run: npm run check diff --git a/.vscode/launch.json b/.vscode/launch.json index a3fb43c..969ac60 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,10 +8,7 @@ "args": [ "--extensionDevelopmentPath=${workspaceFolder}" ], - "outFiles": [ - "${workspaceFolder}/dist/**/*.js" - ], - "preLaunchTask": "npm: compile" + "preLaunchTask": "npm: lint" } ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 8c71c89..71dac82 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -3,18 +3,11 @@ "tasks": [ { "type": "npm", - "script": "compile", + "script": "lint", "group": { "kind": "build", "isDefault": true - }, - "problemMatcher": "$tsc" - }, - { - "type": "npm", - "script": "watch", - "isBackground": true, - "problemMatcher": "$tsc-watch" + } } ] } diff --git a/.vscodeignore b/.vscodeignore index b29ecf3..93c3d38 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,12 +1,26 @@ .github/** .vscode/** -src/** -dist/test/** +src/test/** coverage/** -node_modules/** +node_modules/**/*.ts +node_modules/**/*.tsx +node_modules/**/*.cts +node_modules/**/*.mts +node_modules/**/tsconfig*.json +node_modules/**/*.map +node_modules/**/README*.md +node_modules/**/examples/** +node_modules/**/example/** +node_modules/**/test/** +node_modules/**/tests/** +node_modules/**/docs/** +node_modules/exceljs/dist/** +node_modules/sql.js/dist/*debug* +node_modules/sql.js/dist/sql-asm* +node_modules/sql.js/dist/worker.* +node_modules/sql.js/dist/sql-wasm-browser* .gitignore eslint.config.mjs -tsconfig.json plan.txt project requirements.txt *.vsix diff --git a/CHANGELOG.md b/CHANGELOG.md index 0792c18..e68d20f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,40 @@ # Changelog -Todos los cambios relevantes de Simple DB se documentarán en este archivo. +Todos los cambios relevantes de Simple DB se documentan en este archivo. + +## 0.1.1 - 2026-08-07 + +### Añadido + +- SQLite como quinto motor, ejecutado con `sql.js` en un Worker cancelable. +- Adaptadores completos para SQLite, PostgreSQL, MySQL, SQL Server y Oracle. +- Perfiles múltiples, prueba de conexión, conexión/desconexión y contraseñas en `SecretStorage`. +- Exploración de bases, esquemas y objetos específicos: tablas, vistas, vistas materializadas, rutinas, packages Oracle, índices, triggers, secuencias, tipos, sinónimos y eventos MySQL según el motor. +- Editor SQL por sesión y contexto de base/esquema. +- Parser de dialecto para PostgreSQL `$$`, MySQL `DELIMITER`, SQL Server `GO`, Oracle PL/SQL `/` y triggers SQLite. +- Ejecución de selección, sentencia actual o documento; DML, DDL y SQL arbitrario. +- Lectura/plantillas de DDL con acciones para mostrar definición, preparar `CREATE`, `ALTER` y `DROP`. +- Transacciones por editor, `COMMIT`, `ROLLBACK`, cancelación y timeouts. +- Resultados paginados en disco, múltiples result sets, copia de celda/fila/selección y exportación CSV/JSON/XLSX. +- Historial configurable, duración, filas recuperadas y filas afectadas. +- Confirmaciones configurables para operaciones destructivas y DML sin `WHERE`. +- Pruebas automatizadas íntegramente en JavaScript, incluida integración real del adaptador SQLite. +- Protección SQLite frente a cambios externos y WAL activo, además de lectura exacta de enteros de 64 bits. +- DDL específico por motor para índices/triggers y eliminación correcta de índices respaldados por constraints cuando el catálogo aporta esa información. +- Protección de inicios de transacción escritos como SQL para mantener PostgreSQL/MySQL sobre una conexión física reservada, incluidas variantes de `START TRANSACTION`. +- Protección frente a inyección de fórmulas al exportar CSV y preservación de `NUMBER` Oracle como texto exacto. + +### Cambiado + +- Proyecto migrado completamente de TypeScript a JavaScript: no quedan `.ts`, `tsconfig.json` ni compilación TypeScript. +- `F5` carga directamente `src/extension.js`. +- `simpleDb.maxRows` pasa a `0` por defecto, es decir, **sin límite de filas**. El usuario puede configurar uno si lo necesita. +- La paginación de resultados es almacenamiento/visualización y no altera el SQL con `TOP`, `LIMIT` o `FETCH`. +- CI verifica JavaScript mediante lint, tests y empaquetado VSIX. +- Dependencias bloqueadas por lockfile y auditoría npm integrada en CI. ## 0.0.1 - 2026-08-05 ### Añadido -- Esqueleto de extensión para Visual Studio Code escrito en TypeScript. -- Contenedor Simple DB y vista lateral inicial de conexiones. -- Catálogo inicial de PostgreSQL, MySQL, SQL Server y Oracle. -- Configuración de compilación, ESLint, pruebas y depuración con F5. -- Empaquetado VSIX y flujo de verificación en GitHub Actions. +- Esqueleto inicial de la extensión. diff --git a/README.md b/README.md index 271ec23..fa0b205 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,162 @@ # Simple DB -Simple DB será una extensión de Visual Studio Code para trabajar desde una sola interfaz con PostgreSQL, MySQL, SQL Server y Oracle. +Simple DB `0.1.1` es una extensión de Visual Studio Code, escrita íntegramente en JavaScript, para trabajar con **SQLite, PostgreSQL, MySQL, SQL Server y Oracle** desde una interfaz común. -## Estado actual +La extensión abre documentos SQL normales de VS Code. `F5` inicia directamente `src/extension.js`: no hay TypeScript, `tsconfig.json`, carpeta `dist` ni paso de compilación. -La versión `0.0.1` contiene el esqueleto técnico del proyecto: +## Funciones principales -- Extensión escrita en TypeScript. -- Contenedor propio de **Simple DB** en la barra de actividad. -- Vista inicial **Conexiones** con los cuatro motores previstos. -- Compilación, análisis estático y pruebas automatizadas. -- Configuración para iniciar una ventana de desarrollo con `F5`. -- Empaquetado local en formato `.vsix`. -- Verificación automática mediante GitHub Actions. +- Crear, editar, probar y eliminar múltiples conexiones por motor. +- Contraseñas en `SecretStorage`; nunca dentro de los perfiles ni del repositorio. +- Conectar varios motores simultáneamente y desconectarlos de forma explícita. +- Explorar bases de datos, esquemas y los objetos propios de cada motor. +- Abrir editores SQL vinculados a una conexión, base de datos y esquema. +- Ejecutar la selección, la sentencia del cursor o el documento completo. +- Ejecutar SQL libre: `SELECT`, `INSERT`, `UPDATE`, `DELETE`, `MERGE`, `CREATE`, `ALTER`, `DROP`, `TRUNCATE` y demás sintaxis que acepte el servidor. +- Crear scripts `CREATE`, `ALTER` y `DROP` desde el explorador y consultar la definición/DDL de objetos. +- Transacciones explícitas por editor con `BEGIN`, `COMMIT` y `ROLLBACK`. +- Cancelar consultas y aplicar timeout configurable por conexión. +- Resultados con varias tablas, tipos, `NULL`, filas afectadas, duración y copia de celda/fila/selección. +- Conservación de enteros de 64 bits/`NUMBER` de alta precisión como valores exactos antes de mostrarlos o exportarlos. +- Exportar el resultado ya recuperado a CSV, JSON o XLSX sin volver a ejecutar el SQL. +- Historial local configurable con reapertura, copia y reejecución. -La creación y el almacenamiento seguro de conexiones todavía no forman parte de esta versión. Se incorporarán en los pasos siguientes del plan. +## Cinco motores -## Requisitos para desarrollar +| Motor | Driver | Dialecto y exploración destacada | +|---|---|---| +| SQLite | `sql.js` en Worker | tablas, vistas, índices, triggers, PRAGMA, transacciones | +| PostgreSQL | `pg` + `pg-cursor` | bases, esquemas, tablas, vistas/materializadas, rutinas, índices, triggers, secuencias, tipos, `$$` | +| MySQL | `mysql2` | bases/esquemas, tablas, vistas, rutinas, índices, triggers, eventos, `DELIMITER` | +| SQL Server | `mssql` | bases, esquemas, tablas, vistas, procedimientos/funciones, índices, triggers, secuencias, tipos, sinónimos, `GO` | +| Oracle | `oracledb` Thin | esquemas, tablas, vistas/materializadas, procedimientos/funciones, packages, índices, triggers, secuencias, tipos, sinónimos, PL/SQL | + +Oracle utiliza el modo Thin predeterminado de `node-oracledb`, por lo que las conexiones habituales no requieren instalar Oracle Client. SQL Server `0.1.1` utiliza autenticación SQL con usuario y contraseña. + +## Resultados sin límite impuesto por defecto + +`simpleDb.maxRows` vale **`0` por defecto: sin límite de filas**. + +Simple DB no añade automáticamente `TOP`, `LIMIT` ni `FETCH` a una consulta. Los drivers consumen los resultados mediante cursor, result set o streaming y el almacenamiento temporal se divide en páginas para no enviar todas las filas de golpe al webview. + +`simpleDb.resultPageSize` (500 por defecto) es únicamente el tamaño de página de almacenamiento/visualización; **no es un límite de filas**. Si el usuario quiere un límite, puede asignar a `simpleDb.maxRows` un valor mayor que cero. + +## Explorador y DDL + +Los grupos visibles dependen del motor. Desde un objeto se puede: + +- abrir `SELECT *` para tablas, vistas y vistas materializadas, sin límite SQL añadido; +- mostrar su definición cuando el catálogo del servidor la ofrece; +- preparar un script `ALTER`; +- preparar un script `DROP`; +- copiar su nombre cualificado. + +Desde una base, esquema o grupo se puede preparar un `CREATE` del tipo correspondiente. Los scripts se abren primero en un editor: el usuario los revisa y decide si los ejecuta. + +Los `DROP`/`TRUNCATE` solicitan confirmación por defecto. También se avisa antes de `UPDATE` o `DELETE` sin `WHERE`. Ambos comportamientos son configurables. + +## Ejecución por dialecto + +El documento no se divide con un `split(';')`. El parser entiende: + +- PostgreSQL: strings, comentarios y bloques dollar-quoted como `$$ ... $$`; +- MySQL: `DELIMITER`, strings, comentarios `--`, `/* ... */` y `#`; +- SQL Server: batches `GO` y `GO n`; +- Oracle: bloques `DECLARE`/`BEGIN`, procedimientos, funciones, packages, tipos, triggers, terminador `/` y literales `q'[...]'`; +- SQLite: `CREATE TRIGGER ... BEGIN ... END` con sentencias internas. + +La ejecución de un documento se detiene en el primer error y selecciona el bloque que falló. La selección explícita se procesa dentro de sus límites y los separadores de cliente (`GO`, `DELIMITER`, `/`) no se envían al servidor. + +## Transacciones + +Cada editor SQL tiene un identificador de sesión independiente. Una transacción reserva su conexión física hasta `COMMIT` o `ROLLBACK`. + +- Cerrar un editor con una transacción activa provoca `ROLLBACK` y muestra un aviso. +- Desconectar una conexión con transacciones abiertas requiere confirmación y realiza `ROLLBACK`. +- Tras un error/cancelación dentro de una transacción, la barra de estado puede exigir `ROLLBACK`. +- SQLite impide que otra pestaña utilice el mismo adaptador mientras un editor tiene una transacción abierta. + +## Resultados, copia y exportación + +El panel de resultados permite navegar por páginas, cambiar entre múltiples result sets, distinguir `NULL`, seleccionar una celda o un rango con `Shift` y copiar celda, fila o selección. + +CSV, JSON y XLSX se generan en streaming a partir de las páginas temporales recuperadas. No se reejecuta la consulta. Los valores de celda muy grandes se acotan para la vista mediante `simpleDb.maxCellCharacters`; el texto indica explícitamente cuando una celda fue recortada. + +La exportación CSV protege por defecto valores que podrían interpretarse como fórmulas al abrirlos en una hoja de cálculo. Puede desactivarse si se necesita una exportación CSV literal. + +## Conexiones y seguridad + +- Los perfiles no contienen contraseñas. +- Las contraseñas se guardan con la API `SecretStorage` de VS Code. +- SSL/TLS, cifrado y confianza del certificado son opciones explícitas según el motor. +- `simpleDb.confirmDestructiveQueries` está activado por defecto. +- `simpleDb.warnUnsafeDml` está activado por defecto. +- El historial puede contener literales escritos en SQL. Puede desactivarse con `simpleDb.history.enabled` o vaciarse desde la vista **Historial**. + +### Nota SQLite + +SQLite se ejecuta en un Worker dedicado mediante WebAssembly para que consultas largas no bloqueen la interfaz y puedan cancelarse terminando el Worker. El archivo se mantiene como una instantánea cargada durante la conexión. Antes de cada operación, Simple DB comprueba que el archivo principal/WAL/journal no haya cambiado externamente; ante un conflicto se niega a continuar y pide reconectar. Si al abrir existe un WAL activo, la conexión se rechaza hasta que el proceso propietario haga checkpoint/cierre el WAL, evitando cargar o sobrescribir una instantánea incompleta. + +## Configuración + +| Ajuste | Predeterminado | Función | +|---|---:|---| +| `simpleDb.maxRows` | `0` | Límite opcional por result set; `0` = ilimitado | +| `simpleDb.resultPageSize` | `500` | Filas por página temporal/visual | +| `simpleDb.maxCellCharacters` | `10000` | Máximo conservado por celda en resultados | +| `simpleDb.history.enabled` | `true` | Guardar historial local | +| `simpleDb.history.maxEntries` | `500` | Entradas máximas de historial | +| `simpleDb.confirmDestructiveQueries` | `true` | Confirmar `DROP`/`TRUNCATE` | +| `simpleDb.warnUnsafeDml` | `true` | Avisar de `UPDATE`/`DELETE` sin `WHERE` | +| `simpleDb.csvDelimiter` | `;` | Delimitador de exportación CSV | +| `simpleDb.csvProtectFormulaInjection` | `true` | Neutralizar posibles fórmulas al exportar CSV | + +El timeout de conexión y el timeout máximo de consulta se configuran por perfil. `0` en el timeout de consulta significa sin timeout. + +## Desarrollo + +Requisitos: - Visual Studio Code `1.95.0` o posterior. -- Node.js `20` o posterior. +- Node.js `20` o posterior para desarrollo. - npm. -## Puesta en marcha - -1. Clona el repositorio. -2. Ejecuta `npm install`. -3. Abre la carpeta del proyecto en Visual Studio Code. -4. Pulsa `F5` y elige **Ejecutar Simple DB**. -5. En la nueva ventana de VS Code, abre el icono **Simple DB** de la barra lateral. +```bash +npm ci +npm run check +``` -La vista **Conexiones** debe mostrar PostgreSQL, MySQL, SQL Server y Oracle. +Después abre el repositorio en VS Code y pulsa `F5` con la configuración **Ejecutar Simple DB**. El `preLaunchTask` ejecuta ESLint y el Extension Host carga `src/extension.js` directamente. -## Comandos de desarrollo +Comandos del proyecto: | Comando | Función | |---|---| -| `npm run compile` | Compila TypeScript en la carpeta `dist`. | -| `npm run watch` | Recompila al detectar cambios. | -| `npm run lint` | Revisa el código con ESLint. | -| `npm test` | Ejecuta las pruebas unitarias. | -| `npm run check` | Compila, revisa y prueba todo el proyecto. | -| `npm run package` | Verifica el proyecto y genera el archivo VSIX. | - -## Estructura principal - -- `src/extension.ts`: punto de activación de la extensión. -- `src/databaseEngines.ts`: catálogo inicial de motores. -- `src/views/`: proveedores de las vistas laterales. -- `src/test/`: pruebas unitarias. -- `resources/`: recursos visuales de la extensión. -- `.vscode/`: tareas y configuración de depuración. -- `.github/workflows/`: verificación automática. -- `plan.txt`: plan completo de desarrollo. +| `npm run lint` | ESLint sobre JavaScript | +| `npm test` | Pruebas Vitest escritas en JavaScript | +| `npm run check` | Lint + tests | +| `npm run package` | Verificación y creación del VSIX | +| `npm run package:win32` | VSIX objetivo `win32-x64` | +| `npm run package:linux` | VSIX objetivo `linux-x64` | + +Las pruebas incluyen parser/safety/DDL/almacenamiento/SecretStorage simulado y una integración SQLite real. Los servidores PostgreSQL, MySQL, SQL Server y Oracle externos requieren sus credenciales/infraestructura para pruebas de integración contra una instancia real. + +## Estructura + +```text +src/ + adapters/ # cinco adaptadores y Worker SQLite + core/ # errores y normalización de valores + managers/ # conexiones y sesiones de editor + services/ # ejecución y exportación + sql/ # splitter por dialecto, safety y DDL + storage/ # perfiles, historial y resultados paginados + test/ # tests JavaScript + ui/ # formulario de conexión + views/ # árboles y panel de resultados + extension.js # activate/deactivate +``` ## Licencia -Este proyecto se distribuye bajo la licencia MIT. +MIT. Consulta `LICENSE`. diff --git a/eslint.config.mjs b/eslint.config.mjs index 1be0702..f192793 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,20 +1,34 @@ import eslint from '@eslint/js'; -import tseslint from 'typescript-eslint'; +import globals from 'globals'; -export default tseslint.config( +export default [ { - ignores: [ - 'coverage/**', - 'dist/**', - 'node_modules/**', - ], + ignores: ['coverage/**', 'node_modules/**'], }, eslint.configs.recommended, - ...tseslint.configs.recommended, { - files: ['src/**/*.ts'], + files: ['src/**/*.js', 'scripts/**/*.js'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'commonjs', + globals: { + ...globals.node, + }, + }, rules: { - '@typescript-eslint/consistent-type-imports': 'error', + 'no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, + ], + }, + }, + { + files: ['src/test/**/*.js'], + languageOptions: { + globals: { + ...globals.node, + ...globals.vitest, + }, }, }, -); +]; diff --git a/package-lock.json b/package-lock.json index 5134223..da3a75f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,27 @@ { "name": "simple-db-suzdalenko", - "version": "0.0.1", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simple-db-suzdalenko", - "version": "0.0.1", + "version": "0.1.1", "license": "MIT", + "dependencies": { + "exceljs": "4.4.0", + "mssql": "12.7.0", + "mysql2": "3.23.2", + "oracledb": "7.0.1", + "pg": "8.22.0", + "pg-cursor": "2.21.0", + "sql.js": "1.14.1" + }, "devDependencies": { "@eslint/js": "9.39.5", - "@types/node": "20.19.43", - "@types/vscode": "1.95.0", "@vscode/vsce": "3.9.2", "eslint": "9.39.5", - "typescript": "5.9.3", - "typescript-eslint": "8.66.0", + "globals": "17.9.0", "vitest": "3.2.7" }, "engines": { @@ -39,11 +45,27 @@ "@azu/format-text": "^1.0.1" } }, + "node_modules/@azure-rest/core-client": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.8.0.tgz", + "integrity": "sha512-F1ybHeN+++QhyFCF/ehLUEvrOB6fehPdFBFtGdj0C3B2lpQ9zkPiO5JDgsqc6IfjuUe6b3dAbXK0a7+VgSGfhw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.24.0", + "@azure/core-tracing": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@azure/abort-controller": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz", "integrity": "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.6.2" @@ -56,7 +78,6 @@ "version": "1.11.0", "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.11.0.tgz", "integrity": "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==", - "dev": true, "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.1.2", @@ -71,7 +92,6 @@ "version": "1.11.0", "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.0.tgz", "integrity": "sha512-JjQWO6akOck45PH/XBrxzsQGAiKrfFl4m5iggJ0ItMIz5omRufOXWpqCPpdjKN3vKDzlSUvFjaMb7Zwf0gvAdA==", - "dev": true, "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.1.2", @@ -86,11 +106,37 @@ "node": ">=22.0.0" } }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.7.0.tgz", + "integrity": "sha512-7GEAoIsaoBr6KELNRb8nypowCqvk8dnCHFCYg4XD4lOQGY2GqjQg5IhkRjyBFRO18CGSMq05PaNqSOE9GQro3g==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@azure/core-rest-pipeline": { "version": "1.25.0", "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", "integrity": "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==", - "dev": true, "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.1.2", @@ -109,7 +155,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.4.0.tgz", "integrity": "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.6.2" @@ -122,7 +167,6 @@ "version": "1.14.0", "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.14.0.tgz", "integrity": "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==", - "dev": true, "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.1.2", @@ -137,7 +181,6 @@ "version": "4.13.1", "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", - "dev": true, "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", @@ -156,11 +199,51 @@ "node": ">=20.0.0" } }, + "node_modules/@azure/keyvault-common": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@azure/keyvault-common/-/keyvault-common-2.1.0.tgz", + "integrity": "sha512-aCDidWuKY06LWQ4x7/8TIXK6iRqTaRWRL3t7T+LC+j1b07HtoIsOxP/tU90G4jCSBn5TAyUTCtA4MS/y5Hudaw==", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-rest-pipeline": "^1.8.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.10.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/keyvault-keys": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/@azure/keyvault-keys/-/keyvault-keys-4.10.2.tgz", + "integrity": "sha512-VmUSLbXRAbSzDD8grXHGPaknYs0SKr3yuf6U+d4XMpX4XuVYskNqbTTwXce0zR1LyxfTZm9rWEBcvs3vdYwCmQ==", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-lro": "^2.7.2", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.0", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/keyvault-common": "^2.1.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@azure/logger": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.4.0.tgz", "integrity": "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==", - "dev": true, "license": "MIT", "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", @@ -174,7 +257,6 @@ "version": "5.18.0", "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.18.0.tgz", "integrity": "sha512-SPTeHYZghdEdRddJzNjhH+CI5MSQtquNYwGJnYXfOHIBRXCmrWimBS85OhwXpXFIlrCtNTbBPm5mPAWRNEoktA==", - "dev": true, "license": "MIT", "dependencies": { "@azure/msal-common": "16.12.0" @@ -187,7 +269,6 @@ "version": "16.12.0", "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.12.0.tgz", "integrity": "sha512-hgLgfRdbG2AmhXPygebf1KYJEvse86+ZZLWufdiTKaGRYEUqOzHdlf6AS1IiuUCHWbynkgbHc451jSNkbfhWlg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.0" @@ -197,7 +278,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.5.0.tgz", "integrity": "sha512-A/2WIsuH0vsC6JVkkafjS4kHpi2LDR4AzDT0kJ+oIRtXYeYtvGQ2pwN2X88thQPhSek+82ela3MprsKXWQRrhQ==", - "dev": true, "license": "MIT", "dependencies": { "@azure/msal-common": "16.12.0", @@ -847,6 +927,19 @@ "concat-map": "0.0.1" } }, + "node_modules/@eslint/eslintrc/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/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -904,6 +997,47 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -977,6 +1111,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@js-joda/core": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-6.1.0.tgz", + "integrity": "sha512-H8NTMRDJqad/leyv/D/A3kSOsf5/58Ydj4DJGDyaCWk9OU/zuZOLhndVffJgQjsgrn5GC0znHMHie7TfvPPG4w==", + "license": "BSD-3-Clause" + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -1569,6 +1709,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@tediousjs/connection-string": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-1.1.0.tgz", + "integrity": "sha512-z9ZBWEG+8pIB5V1zYzlRPXx0oRJ5H7coPnMQK8EZOw03UTPI9Umn6viL36f5w+CuqkKsnCM50RVStpjZmR0Bng==", + "license": "MIT" + }, "node_modules/@textlint/ast-node-types": { "version": "15.8.0", "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.8.0.tgz", @@ -1688,13 +1834,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.43", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", - "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", - "dev": true, + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~8.3.0" } }, "node_modules/@types/normalize-package-data": { @@ -1704,6 +1849,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/readable-stream": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz", + "integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/sarif": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", @@ -1711,261 +1865,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/vscode": { - "version": "1.95.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.95.0.tgz", - "integrity": "sha512-0LBD8TEiNbet3NvWsmn59zLzOFu/txSlGxnv5yAFHCrhG9WvAnR3IvfHzMOs2aeWqgvNjq9pO99IUw8d3n+unw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", - "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.66.0", - "@typescript-eslint/type-utils": "8.66.0", - "@typescript-eslint/utils": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.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.66.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", - "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", - "debug": "^4.4.3" - }, - "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 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", - "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.66.0", - "@typescript-eslint/types": "^8.66.0", - "debug": "^4.4.3" - }, - "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.1.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", - "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.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.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", - "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", - "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.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", - "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0", - "@typescript-eslint/utils": "8.66.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.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 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", - "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", - "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.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", - "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.66.0", - "@typescript-eslint/tsconfig-utils": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.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.1.0" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", - "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.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 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", - "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.66.0", - "eslint-visitor-keys": "^5.0.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/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@typespec/ts-http-runtime": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.8.tgz", "integrity": "sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA==", - "dev": true, "license": "MIT", "dependencies": { "http-proxy-agent": "^7.0.0", @@ -2283,6 +2186,18 @@ "win32" ] }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -2310,7 +2225,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -2378,8 +2292,126 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/argparse": { - "version": "2.0.1", + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/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==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "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==", "dev": true, @@ -2405,6 +2437,12 @@ "node": ">=8" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2412,6 +2450,15 @@ "dev": true, "license": "MIT" }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/azure-devops-node-api": { "version": "12.5.0", "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", @@ -2437,7 +2484,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -2452,8 +2498,29 @@ "url": "https://feross.org/support" } ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", "license": "MIT", - "optional": true + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } }, "node_modules/binaryextensions": { "version": "6.11.0", @@ -2475,15 +2542,19 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -2528,7 +2599,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -2544,7 +2614,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -2554,7 +2623,6 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -2564,14 +2632,29 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "dev": true, "license": "BSD-3-Clause" }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, "license": "MIT", "dependencies": { "run-applescript": "^7.0.0" @@ -2651,6 +2734,18 @@ "node": ">=18" } }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2783,13 +2878,58 @@ "node": ">=18" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "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/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2835,11 +2975,16 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, "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" @@ -2902,7 +3047,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "dev": true, "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", @@ -2919,7 +3063,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2932,7 +3075,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2951,6 +3093,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3036,11 +3187,49 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" @@ -3088,9 +3277,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "once": "^1.4.0" } @@ -3451,6 +3638,44 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -3472,6 +3697,19 @@ "node": ">=12.0.0" } }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.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", @@ -3635,9 +3873,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fs-extra": { "version": "11.4.0", @@ -3654,6 +3890,12 @@ "node": ">=14.14" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3669,6 +3911,22 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3679,6 +3937,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3758,9 +4025,9 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "17.9.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz", + "integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==", "dev": true, "license": "MIT", "engines": { @@ -3818,7 +4085,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -3923,7 +4189,6 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -3937,7 +4202,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -3964,7 +4228,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -3979,8 +4242,7 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/ignore": { "version": "5.3.2", @@ -3992,6 +4254,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4032,13 +4300,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", @@ -4052,7 +4329,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, "license": "MIT", "bin": { "is-docker": "cli.js" @@ -4101,7 +4377,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, "license": "MIT", "dependencies": { "is-docker": "^3.0.0" @@ -4126,11 +4401,16 @@ "node": ">=0.12.0" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "dev": true, "license": "MIT", "dependencies": { "is-inside-container": "^1.0.0" @@ -4142,6 +4422,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4167,6 +4453,12 @@ "url": "https://bevry.me/fund" } }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4255,7 +4547,6 @@ "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "dev": true, "license": "MIT", "dependencies": { "jws": "^4.0.1", @@ -4274,23 +4565,63 @@ "npm": ">=6" } }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "dev": true, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" } }, "node_modules/jws": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "dev": true, "license": "MIT", "dependencies": { "jwa": "^2.0.1", @@ -4320,6 +4651,48 @@ "json-buffer": "3.0.1" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -4344,6 +4717,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/linkify-it": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", @@ -4364,6 +4746,12 @@ "uc.micro": "^2.0.0" } }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4387,46 +4775,95 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", "license": "MIT" }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", "license": "MIT" }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isstring": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -4440,7 +4877,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.truncate": { @@ -4450,6 +4886,24 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -4470,6 +4924,21 @@ "node": ">=10" } }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4619,9 +5088,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", - "optional": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -4636,6 +5103,18 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -4648,9 +5127,36 @@ "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/mssql": { + "version": "12.7.0", + "resolved": "https://registry.npmjs.org/mssql/-/mssql-12.7.0.tgz", + "integrity": "sha512-J6SJKXi1jYbhHjjooLNtPnX7+s3cq5IJ701Wgy/UW1SXRpgFlJJsYi3IPve9RVgCUkq0Cqv2aaaSJ4IXtIF3mg==", + "license": "MIT", + "dependencies": { + "@tediousjs/connection-string": "^1.0.0", + "commander": "^11.0.0", + "debug": "^4.3.3", + "tarn": "^3.0.2", + "tedious": "^19.2.2 || ^20.0.0" + }, + "bin": { + "mssql": "bin/mssql" + }, + "engines": { + "node": ">=18.19.0" + } + }, + "node_modules/mssql/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -4658,6 +5164,56 @@ "dev": true, "license": "ISC" }, + "node_modules/mysql2": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.2.tgz", + "integrity": "sha512-fxh3HpQ8vJtu/Mmnd4Xsur19jGjHGzRLMxptiDtOkbX7EVBgnafGSGDx1WGGVmJLClVh2LeeBMMo24IFv8wCyQ==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.17", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", @@ -4685,6 +5241,12 @@ "license": "MIT", "optional": true }, + "node_modules/native-duplexpair": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", + "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -4763,6 +5325,15 @@ "dev": true, "license": "ISC" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -4793,9 +5364,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", - "optional": true, "dependencies": { "wrappy": "1" } @@ -4804,7 +5373,6 @@ "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "dev": true, "license": "MIT", "dependencies": { "default-browser": "^5.2.1", @@ -4837,6 +5405,16 @@ "node": ">= 0.8.0" } }, + "node_modules/oracledb": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/oracledb/-/oracledb-7.0.1.tgz", + "integrity": "sha512-xlM0Ceh6A5stQLAdEfKf3pgCSkbOjQLo2ZPEi3+kXklz+KbZD3fLi/nsTSbQeZZNFBSFDNxdn1Ek3+bxG40M8w==", + "hasInstallScript": true, + "license": "(Apache-2.0 OR UPL-1.0)", + "engines": { + "node": ">=14.17" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4882,6 +5460,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4996,6 +5580,15 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5070,6 +5663,104 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-cursor": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.21.0.tgz", + "integrity": "sha512-IYvk/j+Suhtbo/C3uOf4JLsLK/gWxOTUOmYbDsbKnLaVJDq+KwhwK6ngpRfiCk8eDMS3AmGQABZCv0cREEzHQw==", + "license": "MIT", + "peerDependencies": { + "pg": "^8" + } + }, + "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.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "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", @@ -5129,6 +5820,45 @@ "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.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "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/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -5168,6 +5898,21 @@ "node": ">= 0.8.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -5329,16 +6074,50 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/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==", + "license": "MIT" + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">= 6" + "node": ">=10" } }, "node_modules/require-from-string": { @@ -5372,6 +6151,68 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/rimraf/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==", + "license": "MIT" + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/rollup": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", @@ -5422,7 +6263,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -5459,7 +6299,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -5480,7 +6319,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/sax": { @@ -5493,6 +6331,18 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/secretlint": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", @@ -5519,7 +6369,6 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5528,6 +6377,12 @@ "node": ">=10" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5760,6 +6615,42 @@ "dev": true, "license": "CC0-1.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/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "license": "MIT" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -5778,9 +6669,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -5970,9 +6859,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -5984,6 +6871,104 @@ "node": ">=6" } }, + "node_modules/tarn": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.1.2.tgz", + "integrity": "sha512-3RTvqKZcK/17jnJ8rMKFXbyNogywTs1z0gVPPwFsJGX46rkmUHOdIaSQ/aVO1rS7nH+soiXiWk7rvUXxndm8Dg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/tedious": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/tedious/-/tedious-20.0.0.tgz", + "integrity": "sha512-bTR0aou0Ghucf0ytvZUJjnKHGKDV8tT57jPYtEkSpfTWFe++4uR1wxJLQ4mh5wlSvAYXzmgBxbI0vaE56qigXw==", + "license": "MIT", + "dependencies": { + "@azure/core-auth": "^1.10.1", + "@azure/identity": "^4.13.1", + "@azure/keyvault-keys": "^4.10.2", + "@js-joda/core": "^6.0.1", + "@types/node": ">=22", + "bl": "^6.1.4", + "iconv-lite": "^0.7.0", + "js-md4": "^0.3.2", + "native-duplexpair": "^1.0.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/tedious/node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/tedious/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/tedious/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/tedious/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/terminal-link": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", @@ -6120,7 +7105,6 @@ "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", - "dev": true, "license": "MIT", "engines": { "node": ">=14.14" @@ -6139,24 +7123,19 @@ "node": ">=8.0" } }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" + "node": "*" } }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tunnel": { @@ -6221,44 +7200,6 @@ "underscore": "^1.12.1" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz", - "integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.66.0", - "@typescript-eslint/parser": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0", - "@typescript-eslint/utils": "8.66.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 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", @@ -6284,10 +7225,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, "node_modules/unicorn-magic": { @@ -6313,6 +7253,54 @@ "node": ">= 10.0.0" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -6334,9 +7322,20 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", - "optional": true + "bin": { + "uuid": "dist/esm/bin/uuid" + } }, "node_modules/validate-npm-package-license": { "version": "3.0.4", @@ -6648,15 +7647,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "dev": true, "license": "MIT", "dependencies": { "is-wsl": "^3.1.0" @@ -6692,6 +7688,21 @@ "node": ">=4.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, + "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/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -6734,6 +7745,90 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/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==", + "license": "MIT" + }, + "node_modules/zip-stream/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/zip-stream/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/zip-stream/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } } } } diff --git a/package.json b/package.json index b09ff69..d91c734 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "simple-db-suzdalenko", "displayName": "Simple DB", - "description": "Cliente de bases de datos para PostgreSQL, MySQL, SQL Server y Oracle dentro de Visual Studio Code.", - "version": "0.0.1", + "description": "Cliente SQL para SQLite, PostgreSQL, MySQL, SQL Server y Oracle dentro de Visual Studio Code.", + "version": "0.1.1", "publisher": "suzdalenko-dev", "license": "MIT", "repository": { @@ -26,6 +26,7 @@ "keywords": [ "database", "sql", + "sqlite", "postgresql", "mysql", "sql-server", @@ -33,16 +34,170 @@ ], "activationEvents": [ "onView:simpleDb.connections", - "onCommand:simpleDb.refreshConnections" + "onView:simpleDb.history", + "onLanguage:sql" ], - "main": "./dist/extension.js", + "main": "./src/extension.js", "contributes": { "commands": [ + { + "command": "simpleDb.addConnection", + "title": "Añadir conexión", + "category": "Simple DB", + "icon": "$(add)" + }, { "command": "simpleDb.refreshConnections", "title": "Actualizar conexiones", "category": "Simple DB", "icon": "$(refresh)" + }, + { + "command": "simpleDb.connect", + "title": "Conectar", + "category": "Simple DB", + "icon": "$(plug)" + }, + { + "command": "simpleDb.disconnect", + "title": "Desconectar", + "category": "Simple DB", + "icon": "$(debug-disconnect)" + }, + { + "command": "simpleDb.testConnection", + "title": "Probar conexión", + "category": "Simple DB", + "icon": "$(beaker)" + }, + { + "command": "simpleDb.editConnection", + "title": "Editar conexión", + "category": "Simple DB", + "icon": "$(edit)" + }, + { + "command": "simpleDb.deleteConnection", + "title": "Eliminar conexión", + "category": "Simple DB", + "icon": "$(trash)" + }, + { + "command": "simpleDb.newQuery", + "title": "Nueva consulta", + "category": "Simple DB", + "icon": "$(new-file)" + }, + { + "command": "simpleDb.changeEditorConnection", + "title": "Cambiar conexión del editor", + "category": "Simple DB" + }, + { + "command": "simpleDb.executeCurrent", + "title": "Ejecutar selección o sentencia actual", + "category": "Simple DB", + "icon": "$(play)" + }, + { + "command": "simpleDb.executeSelection", + "title": "Ejecutar selección", + "category": "Simple DB" + }, + { + "command": "simpleDb.executeDocument", + "title": "Ejecutar documento completo", + "category": "Simple DB", + "icon": "$(run-all)" + }, + { + "command": "simpleDb.cancelQuery", + "title": "Cancelar consulta", + "category": "Simple DB", + "icon": "$(debug-stop)" + }, + { + "command": "simpleDb.beginTransaction", + "title": "Begin Transaction", + "category": "Simple DB" + }, + { + "command": "simpleDb.commit", + "title": "Commit", + "category": "Simple DB" + }, + { + "command": "simpleDb.rollback", + "title": "Rollback", + "category": "Simple DB" + }, + { + "command": "simpleDb.showHistory", + "title": "Mostrar historial", + "category": "Simple DB", + "icon": "$(history)" + }, + { + "command": "simpleDb.clearHistory", + "title": "Vaciar historial", + "category": "Simple DB", + "icon": "$(clear-all)" + }, + { + "command": "simpleDb.openHistoryEntry", + "title": "Abrir consulta del historial", + "category": "Simple DB" + }, + { + "command": "simpleDb.copyHistoryEntry", + "title": "Copiar SQL", + "category": "Simple DB" + }, + { + "command": "simpleDb.rerunHistoryEntry", + "title": "Volver a ejecutar", + "category": "Simple DB" + }, + { + "command": "simpleDb.deleteHistoryEntry", + "title": "Eliminar del historial", + "category": "Simple DB" + }, + { + "command": "simpleDb.selectTable", + "title": "Abrir SELECT *", + "category": "Simple DB", + "icon": "$(preview)" + }, + { + "command": "simpleDb.showDefinition", + "title": "Mostrar definición / DDL", + "category": "Simple DB", + "icon": "$(code)" + }, + { + "command": "simpleDb.createObject", + "title": "Crear objeto SQL", + "category": "Simple DB", + "icon": "$(add)" + }, + { + "command": "simpleDb.alterObject", + "title": "Preparar ALTER", + "category": "Simple DB", + "icon": "$(edit)" + }, + { + "command": "simpleDb.dropObjectScript", + "title": "Preparar DROP", + "category": "Simple DB", + "icon": "$(trash)" + }, + { + "command": "simpleDb.copyQualifiedName", + "title": "Copiar nombre cualificado", + "category": "Simple DB", + "icon": "$(copy)" } ], "viewsContainers": { @@ -60,36 +215,260 @@ "id": "simpleDb.connections", "name": "Conexiones", "type": "tree" + }, + { + "id": "simpleDb.history", + "name": "Historial", + "type": "tree" } ] }, "menus": { "view/title": [ + { + "command": "simpleDb.addConnection", + "when": "view == simpleDb.connections", + "group": "navigation@1" + }, { "command": "simpleDb.refreshConnections", "when": "view == simpleDb.connections", - "group": "navigation" + "group": "navigation@2" + }, + { + "command": "simpleDb.clearHistory", + "when": "view == simpleDb.history", + "group": "navigation@1" + } + ], + "view/item/context": [ + { + "command": "simpleDb.addConnection", + "when": "view == simpleDb.connections && viewItem == simpleDb.engine", + "group": "inline@1" + }, + { + "command": "simpleDb.connect", + "when": "view == simpleDb.connections && viewItem == simpleDb.connection.disconnected", + "group": "inline@1" + }, + { + "command": "simpleDb.disconnect", + "when": "view == simpleDb.connections && viewItem =~ /simpleDb.connection.(connected|transaction)/", + "group": "inline@1" + }, + { + "command": "simpleDb.newQuery", + "when": "view == simpleDb.connections && viewItem =~ /simpleDb.connection/", + "group": "inline@2" + }, + { + "command": "simpleDb.newQuery", + "when": "view == simpleDb.connections && viewItem =~ /simpleDb.(database|schema)/", + "group": "inline@1" + }, + { + "command": "simpleDb.testConnection", + "when": "view == simpleDb.connections && viewItem =~ /simpleDb.connection/", + "group": "connection@1" + }, + { + "command": "simpleDb.editConnection", + "when": "view == simpleDb.connections && viewItem =~ /simpleDb.connection/", + "group": "connection@2" + }, + { + "command": "simpleDb.deleteConnection", + "when": "view == simpleDb.connections && viewItem =~ /simpleDb.connection/", + "group": "connection@3" + }, + { + "command": "simpleDb.selectTable", + "when": "view == simpleDb.connections && viewItem =~ /simpleDb.(table|view|materializedView)/", + "group": "inline@1" + }, + { + "command": "simpleDb.showDefinition", + "when": "view == simpleDb.connections && viewItem =~ /simpleDb.(table|view|materializedView|procedure|package|index|trigger|sequence|type|synonym|event)/", + "group": "object@1" + }, + { + "command": "simpleDb.alterObject", + "when": "view == simpleDb.connections && viewItem =~ /simpleDb.(table|view|materializedView|procedure|package|index|trigger|sequence|type|synonym|event)/", + "group": "object@2" + }, + { + "command": "simpleDb.dropObjectScript", + "when": "view == simpleDb.connections && viewItem =~ /simpleDb.(table|view|materializedView|procedure|package|index|trigger|sequence|type|synonym|event)/", + "group": "object@3" + }, + { + "command": "simpleDb.copyQualifiedName", + "when": "view == simpleDb.connections && viewItem =~ /simpleDb.(table|view|materializedView|procedure|package|index|trigger|sequence|type|synonym|event)/", + "group": "object@4" + }, + { + "command": "simpleDb.createObject", + "when": "view == simpleDb.connections && viewItem =~ /simpleDb.(database|schema|group)/", + "group": "object@1" + }, + { + "command": "simpleDb.openHistoryEntry", + "when": "view == simpleDb.history && viewItem == simpleDb.historyEntry", + "group": "inline@1" + }, + { + "command": "simpleDb.rerunHistoryEntry", + "when": "view == simpleDb.history && viewItem == simpleDb.historyEntry", + "group": "history@1" + }, + { + "command": "simpleDb.copyHistoryEntry", + "when": "view == simpleDb.history && viewItem == simpleDb.historyEntry", + "group": "history@2" + }, + { + "command": "simpleDb.deleteHistoryEntry", + "when": "view == simpleDb.history && viewItem == simpleDb.historyEntry", + "group": "history@3" + } + ], + "editor/title": [ + { + "command": "simpleDb.executeCurrent", + "when": "editorLangId == sql", + "group": "navigation@1" + }, + { + "command": "simpleDb.executeDocument", + "when": "editorLangId == sql", + "group": "navigation@2" + }, + { + "command": "simpleDb.cancelQuery", + "when": "editorLangId == sql", + "group": "navigation@3" + } + ], + "editor/context": [ + { + "command": "simpleDb.executeCurrent", + "when": "editorLangId == sql", + "group": "simpleDb@1" + }, + { + "command": "simpleDb.executeSelection", + "when": "editorLangId == sql && editorHasSelection", + "group": "simpleDb@2" + }, + { + "command": "simpleDb.changeEditorConnection", + "when": "editorLangId == sql", + "group": "simpleDb@3" } ] + }, + "keybindings": [ + { + "command": "simpleDb.executeCurrent", + "key": "ctrl+enter", + "mac": "cmd+enter", + "when": "editorTextFocus && editorLangId == sql" + }, + { + "command": "simpleDb.executeDocument", + "key": "ctrl+shift+enter", + "mac": "cmd+shift+enter", + "when": "editorTextFocus && editorLangId == sql" + } + ], + "configuration": { + "title": "Simple DB", + "properties": { + "simpleDb.maxRows": { + "type": "integer", + "default": 0, + "minimum": 0, + "maximum": 100000000, + "description": "Máximo opcional de filas recuperadas por conjunto de resultados. 0 = sin límite. Simple DB no modifica el SQL con TOP/LIMIT/FETCH." + }, + "simpleDb.resultPageSize": { + "type": "integer", + "default": 500, + "minimum": 50, + "maximum": 5000, + "description": "Filas almacenadas y mostradas por página." + }, + "simpleDb.maxCellCharacters": { + "type": "integer", + "default": 10000, + "minimum": 100, + "maximum": 1000000, + "description": "Máximo de caracteres conservados por celda en la vista de resultados." + }, + "simpleDb.history.enabled": { + "type": "boolean", + "default": true, + "description": "Guardar historial local de consultas." + }, + "simpleDb.history.maxEntries": { + "type": "integer", + "default": 500, + "minimum": 0, + "maximum": 5000, + "description": "Número máximo de consultas conservadas en el historial." + }, + "simpleDb.confirmDestructiveQueries": { + "type": "boolean", + "default": true, + "description": "Pedir confirmación antes de DROP o TRUNCATE." + }, + "simpleDb.warnUnsafeDml": { + "type": "boolean", + "default": true, + "description": "Avisar antes de UPDATE o DELETE sin cláusula WHERE." + }, + "simpleDb.csvDelimiter": { + "type": "string", + "default": ";", + "description": "Separador utilizado al exportar resultados a CSV." + }, + "simpleDb.csvProtectFormulaInjection": { + "type": "boolean", + "default": true, + "description": "Protege celdas CSV que comienzan por =, +, -, @, tabulador o retorno de carro para evitar que una hoja de cálculo las interprete como fórmulas." + } + } } }, "scripts": { - "vscode:prepublish": "npm run compile", - "compile": "tsc -p ./tsconfig.json", - "watch": "tsc -watch -p ./tsconfig.json", + "vscode:prepublish": "npm run check", "lint": "eslint src", - "test": "vitest run", - "check": "npm run compile && npm run lint && npm run test", - "package": "npm run check && vsce package --no-dependencies" + "test": "vitest run --globals", + "check": "npm run lint && npm run test", + "package": "npm run check && vsce package", + "package:win32": "npm run check && vsce package --target win32-x64", + "package:linux": "npm run check && vsce package --target linux-x64" + }, + "dependencies": { + "exceljs": "4.4.0", + "mssql": "12.7.0", + "mysql2": "3.23.2", + "oracledb": "7.0.1", + "pg": "8.22.0", + "pg-cursor": "2.21.0", + "sql.js": "1.14.1" }, "devDependencies": { "@eslint/js": "9.39.5", - "@types/node": "20.19.43", - "@types/vscode": "1.95.0", "@vscode/vsce": "3.9.2", "eslint": "9.39.5", - "typescript": "5.9.3", - "typescript-eslint": "8.66.0", + "globals": "17.9.0", "vitest": "3.2.7" + }, + "overrides": { + "exceljs": { + "uuid": "11.1.1" + } } } diff --git a/plan.txt b/plan.txt index c45ef07..67660dd 100644 --- a/plan.txt +++ b/plan.txt @@ -1,463 +1,57 @@ -Plan completo paso a paso -Paso 1. Crear el esqueleto de la extensión - -Crearemos: - -package.json. -Proyecto TypeScript. -Configuración de compilación. -ESLint. -Depuración con F5. -Pruebas. -Empaquetado VSIX. -Icono y contenedor propio en la barra lateral. -README.md, CHANGELOG.md y licencia. -Dependencias bloqueadas mediante package-lock.json. - -Identidad propuesta: - -Nombre del paquete: simple-db-suzdalenko. -Nombre visible: Simple DB. -Publicador: suzdalenko-dev. -Identificador final: suzdalenko-dev.simple-db-suzdalenko. -Primera versión de desarrollo: 0.0.1. - -Terminado cuando npm run compile, npm run lint, las pruebas y F5 funcionen y aparezca el icono de Simple DB. - -Paso 2. Definir el contrato común para los cuatro motores - -Crearemos una interfaz DatabaseAdapter con operaciones como: - -Probar conexión. -Conectar. -Desconectar. -Obtener bases de datos. -Obtener esquemas. -Obtener tablas. -Obtener vistas. -Obtener columnas. -Obtener procedimientos y funciones. -Ejecutar SQL. -Comenzar transacción. -Confirmar transacción. -Deshacer transacción. -Cancelar consulta. -Leer resultados progresivamente. - -También definiremos modelos comunes: - -ConnectionProfile. -QuerySession. -QueryExecution. -QueryResult. -ColumnMetadata. -DatabaseObject. -TransactionState. - -Así, toda la interfaz de VS Code trabajará igual y únicamente cambiará el adaptador interno. - -Paso 3. Crear y guardar conexiones de forma segura - -Crearemos un formulario para: - -Crear conexión. -Editarla. -Probarla. -Guardarla. -Eliminarla. - -Campos comunes: - -Nombre de la conexión. -Motor. -Servidor. -Puerto. -Usuario. -Contraseña. -Base de datos inicial. -Tiempo de conexión. -Tiempo máximo de consulta. -Configuración SSL. - -Campos específicos: - -PostgreSQL: base de datos inicial y SSL. -MySQL: base de datos inicial y SSL. -SQL Server: base de datos, instancia, cifrado y confianza del certificado. -Oracle: servicio/PDB o connectString. - -Puertos predeterminados: - -PostgreSQL: 5432. -MySQL: 3306. -SQL Server: 1433. -Oracle: 1521. - -Las contraseñas no se guardarán en archivos JSON ni en Git. Se almacenarán con SecretStorage. El resto del perfil se guardará en globalState, para poder utilizarlo desde cualquier proyecto abierto en VS Code. - -La prueba de conexión será temporal: conectará, obtendrá la versión del servidor, mostrará el tiempo empleado y cerrará la conexión. - -Paso 4. Crear el navegador lateral y el gestor de conexiones - -El navegador mostrará las conexiones agrupadas por motor y su estado: - -Desconectada. -Conectando. -Conectada. -Error. -Transacción activa. - -Cada conexión tendrá acciones: - -Conectar. -Desconectar. -Probar. -Editar. -Eliminar. -Refrescar. -Abrir nueva consulta. - -El ConnectionManager mantendrá varios pools simultáneos: - -PostgreSQL conectado. -Oracle conectado. -MySQL conectado. -SQL Server conectado. - -Todos podrán permanecer abiertos a la vez. - -Si desconectas una conexión con consultas o transacciones activas, la extensión pedirá confirmación, cancelará las consultas y hará ROLLBACK antes de cerrar. - -Paso 5. Crear el editor SQL y vincularlo a una conexión - -La acción “Nueva consulta” abrirá un documento SQL normal de VS Code. - -Cada editor tendrá asociado: - -Motor. -Perfil de conexión. -Base de datos. -Esquema. -Estado de transacción. -Consultas en ejecución. - -La barra de estado mostrará algo parecido a: - -PostgreSQL | Producción | froxa_db | public | Auto-commit - -o: - -Oracle | Libra | FROXA | TX activa - -Esto es esencial: COMMIT o ROLLBACK afectarán únicamente a la sesión del editor activo, no a otra pestaña. - -Se podrá cambiar la conexión del editor siempre que no tenga una transacción abierta. - -Paso 6. Implementar las tres formas de ejecución - -Añadiremos comandos para ejecutar: - -Documento completo. -Texto seleccionado. -Sentencia donde está situado el cursor. - -Comportamiento: - -Si existe una selección, se ejecuta exactamente esa selección. -Si no existe selección, se localiza la sentencia actual. -Ejecutar documento procesará todos los bloques en orden. -Por defecto se detendrá en el primer error y señalará el bloque que falló. - -Los separadores tendrán que entender cada dialecto: - -SQL general: ;. -PostgreSQL: bloques con comillas dólar como $$ ... $$. -SQL Server: separador GO. -Oracle: bloques DECLARE/BEGIN ... END; y /. -MySQL: bloques y directiva DELIMITER. - -No utilizaremos un simple split(';'), porque rompería PL/SQL, procedimientos, comentarios y cadenas de texto. - -Paso 7. Construir la tabla de resultados - -Los resultados se mostrarán en una tabla webview con: - -Nombre de columnas. -Tipo de dato. -Filas. -Valores NULL diferenciados. -Copiar celda, fila o selección. -Varias tablas cuando una ejecución produzca varios resultados. -Mensajes para DDL y DML. -Número de filas recuperadas o afectadas. -Tiempo de ejecución. -Tiempo total de lectura. -Aviso cuando se alcance el límite. - -Ejemplos: - -SELECT: 42.310 filas recuperadas en 2,84 segundos. -UPDATE: 138 filas afectadas. -CREATE TABLE: ejecución correcta. -SELECT: 100.000 filas — límite alcanzado. - -No enviaremos 100.000 filas de golpe al webview. Se leerán y mostrarán en páginas, por ejemplo de 500 filas. - -Paso 8. Crear el almacenamiento temporal de resultados grandes - -El límite predeterminado será: - -simpleDb.maxRows = 100000 - -También tendremos: - -simpleDb.resultPageSize = 500 - -Los resultados completos hasta el límite se almacenarán temporalmente por páginas. La interfaz solo cargará la página visible. - -Esto permitirá: - -No bloquear VS Code. -Exportar todas las filas recuperadas. -Evitar mantener grandes cantidades de datos en memoria. -Eliminar automáticamente los resultados temporales al cerrar la sesión. - -No añadiremos automáticamente LIMIT, TOP o FETCH al SQL, porque podríamos modificar incorrectamente una consulta. El límite se controlará mediante cursores, result sets o streaming de cada controlador. - -Oracle, por ejemplo, recomienda ResultSet para cantidades grandes de datos en lugar de cargar todas las filas en memoria. Documentación oficial de ejecución y ResultSet. - -Paso 9. Implementar PostgreSQL completamente - -PostgreSQL será el primer motor de referencia. - -Incluiremos: - -Pool de conexiones. -Bases de datos. -Esquemas. -Tablas. -Vistas. -Columnas. -Funciones y procedimientos. -SELECT, INSERT, UPDATE y DELETE. -DDL. -Cursores para resultados grandes. -Transacciones. -Cancelación. -Errores normalizados. - -Las transacciones utilizarán un cliente reservado para el editor. PostgreSQL exige que todos los comandos de una transacción utilicen el mismo cliente; no se puede usar indiscriminadamente pool.query(). Documentación oficial. - -Paso 10. Implementar Oracle - -Lo implementaremos pronto porque es el motor más diferente y porque tú trabajas con Oracle/Libra. - -Incluiremos: - -Conexión por servicio/PDB. -Esquemas accesibles. -ALL_TABLES. -ALL_VIEWS. -ALL_TAB_COLUMNS. -ALL_PROCEDURES. -Funciones y paquetes. -SQL normal. -Bloques PL/SQL. -Result sets. -COMMIT y ROLLBACK. -Cancelación con connection.break(). - -La primera versión ejecutará PL/SQL, pero no intentará emular completamente SQLPlus. Comandos como SPOOL, SET o toda la consola SQLPlus quedarían para una versión posterior. - -También verificaremos antes la versión real de Oracle de Libra para decidir entre Thin y Thick. - -Paso 11. Implementar SQL Server - -Incluiremos: - -Exploración de sys.databases. -Esquemas. -Tablas y vistas. -Columnas. -Procedimientos. -T-SQL. -Bloques separados mediante GO. -Varias tablas de resultados. -Transacciones con mssql.Transaction. -Cancelación mediante Request.cancel(). - -El controlador mssql reserva una conexión durante la transacción y la devuelve al pool después del COMMIT o ROLLBACK, que coincide con nuestra arquitectura de sesión por editor. Documentación de transacciones y cancelación. - -Inicialmente soportaremos autenticación SQL Server con usuario y contraseña. La autenticación integrada de Windows requiere otro controlador nativo y se podrá añadir después si la necesitas. - -Paso 12. Implementar MySQL - -Incluiremos: - -Bases de datos. -Tablas. -Vistas. -Columnas. -Procedimientos y funciones. -SQL y DDL. -Transacciones. -Streaming de filas. -DELIMITER para procedimientos. -Cancelación mediante el identificador de conexión y KILL QUERY, con cierre de conexión como alternativa segura. - -Como en MySQL una base de datos actúa normalmente como esquema, no mostraremos dos niveles idénticos. - -Paso 13. Unificar transacciones - -El comportamiento será igual en los cuatro motores: - -Auto-commit activado de forma predeterminada. -Comando “Begin Transaction”. -Comando “Commit”. -Comando “Rollback”. -Indicador visual de transacción. -Una conexión física reservada mientras exista la transacción. -Prohibición de cambiar de motor durante la transacción. -Aviso si se cierra la pestaña con cambios sin confirmar. -ROLLBACK automático al cerrar de forma inesperada. - -Fuera de una transacción, las consultas podrán utilizar libremente el pool. - -Dentro de una transacción, las consultas se ejecutarán secuencialmente sobre la misma conexión. - -Paso 14. Implementar cancelación y tiempos máximos - -Cada ejecución tendrá un identificador y un controlador de cancelación. - -Métodos previstos: - -Motor Cancelación -PostgreSQL Cancelación del backend utilizando su PID -MySQL KILL QUERY o cierre de la conexión -SQL Server Request.cancel() -Oracle connection.break() - -También habrá: - -Botón “Cancelar consulta”. -Comando desde la paleta. -Progreso cancelable. -Tiempo máximo configurable. -Limpieza correcta de cursores y result sets. -Estado “transacción necesita ROLLBACK” cuando una cancelación deje la transacción abortada. -Paso 15. Exportar CSV, JSON y Excel - -Desde cada resultado se podrá elegir: - -Exportar CSV. -Exportar JSON. -Exportar Excel .xlsx. - -La exportación utilizará el resultado temporal ya recuperado; no volverá a ejecutar la consulta. - -Opciones: - -Nombre y ubicación del archivo. -Delimitador CSV. -Incluir encabezados. -Codificación UTF-8. -Formato de fechas. -Representación de NULL. - -Excel se escribirá progresivamente para que una exportación de 100.000 filas no tenga que construirse completamente en memoria. - -Paso 16. Crear el historial de consultas - -Cada entrada guardará: - -Fecha y hora. -Motor. -Conexión. -Base de datos/esquema. -SQL. -Duración. -Filas. -Resultado correcto o error. - -Acciones: - -Abrir consulta en un editor nuevo. -Volver a ejecutarla. -Copiarla. -Eliminar una entrada. -Vaciar historial. -Activar o desactivar historial. - -El historial será local y tendrá un máximo configurable, por ejemplo 500 consultas. Debemos advertir que una sentencia SQL puede contener datos sensibles aunque no contenga la contraseña de conexión. - -Paso 17. Seguridad y protección frente a errores - -Añadiremos: - -Contraseñas exclusivamente en SecretStorage. -Eliminación de secretos al borrar perfiles. -Nunca imprimir credenciales en logs. -Confirmación opcional para DROP, TRUNCATE y otras operaciones destructivas. -Aviso configurable para DELETE o UPDATE sin WHERE. -Límite de filas. -Límite de tamaño por celda. -Tratamiento especial de BLOB, Buffer, CLOB y valores binarios. -Cierre de pools, conexiones, cursores y transacciones al desactivar la extensión. -Paso 18. Pruebas automáticas y de integración - -Tendremos tres niveles: - -Pruebas unitarias: -Separación de sentencias. -Comentarios y cadenas. -PL/SQL. -GO. -DELIMITER. -Gestión de perfiles. -Transacciones. -Paginación. -Pruebas con bases de datos de desarrollo: -PostgreSQL. -MySQL. -SQL Server. -Oracle Free. -Pruebas dentro de VS Code: -Crear conexión. -Abrir navegador. -Ejecutar consulta. -Cancelar. -Exportar. -Reiniciar VS Code y recuperar perfiles. - -Las operaciones INSERT, UPDATE, DELETE y DDL se probarán únicamente en bases de datos de prueba, nunca en producción. - -Paso 19. Empaquetado y publicación - -Antes de publicar: - -Compilar. -Ejecutar lint. -Ejecutar pruebas. -Generar VSIX. -Instalar manualmente el VSIX. -Probar en Windows. -Probar en Linux. -Verificar PostgreSQL, MySQL, SQL Server y Oracle. -Revisar tamaño del paquete. -Preparar documentación y capturas. -Publicar como beta 0.1.0. - -Si en el futuro utilizamos dependencias nativas, VS Code permite generar paquetes específicos para plataformas como win32-x64 o linux-x64. Documentación de publicación. - -Versiones intermedias propuestas -Versión Resultado -0.0.1 Estructura TypeScript y navegador vacío -0.0.2 CRUD seguro de conexiones -0.0.3 PostgreSQL completo -0.0.4 Oracle completo -0.0.5 SQL Server completo -0.0.6 MySQL completo -0.0.7 Exportaciones, historial y resultados grandes -0.1.0 Primera beta con los cuatro motores - -El siguiente trabajo concreto será únicamente el Paso 1: preparar el esqueleto TypeScript en simple-db-suzdalenko, ejecutarlo con F5 y comprobar que aparece el nuevo navegador lateral. A partir de ahí iremos avanzando paso por paso y probando cada función antes de incorporar la siguiente. \ No newline at end of file +SIMPLE DB 0.1.1 — PLAN DE ENTREGA + +1. Arquitectura JavaScript + [x] Eliminar TypeScript, tsconfig y compilación. + [x] Activación directa desde src/extension.js. + [x] Estructura nativa adapters/core/managers/services/sql/storage/ui/views/test. + +2. Cinco motores + [x] SQLite. + [x] PostgreSQL. + [x] MySQL. + [x] SQL Server. + [x] Oracle. + +3. Conexiones + [x] Crear/editar/probar/eliminar perfiles. + [x] SecretStorage para contraseñas. + [x] Pools/conexiones simultáneas, estados y desconexión segura. + [x] Contexto de base de datos/esquema por editor. + +4. Explorador + [x] Bases/esquemas/tablas/vistas/columnas/rutinas. + [x] Vistas materializadas, índices, triggers, secuencias y tipos. + [x] Packages Oracle, sinónimos y eventos donde corresponda. + [x] Definición de objetos y nombres cualificados. + +5. SQL y DDL + [x] Documento/selección/sentencia actual. + [x] PostgreSQL $$, MySQL DELIMITER/#, SQL Server GO, Oracle PL/SQL/q-quotes, triggers SQLite. + [x] DML/DDL libre. + [x] Plantillas CREATE/ALTER/DROP y lectura DDL. + [x] Confirmación de DROP/TRUNCATE y aviso UPDATE/DELETE sin WHERE. + +6. Transacciones/cancelación + [x] BEGIN/COMMIT/ROLLBACK por editor. + [x] Conexión reservada por transacción. + [x] Cancelación PostgreSQL/MySQL/SQL Server/Oracle/SQLite. + [x] Timeout y estado de rollback requerido. + +7. Resultados + [x] Streaming/cursor/result set por driver. + [x] maxRows=0 sin límite por defecto. + [x] Paginación temporal sin TOP/LIMIT/FETCH añadido. + [x] Múltiples result sets, NULL, tipos, tiempos y filas. + [x] Copiar celda/fila/selección. + [x] Exportación CSV/JSON/XLSX. + [x] Historial configurable. + +8. Calidad y entrega + [x] ESLint JavaScript. + [x] Tests JavaScript, incluida integración SQLite. + [x] Validación final npm run check (50 tests). + [x] Auditoría npm sin vulnerabilidades. + [x] Empaquetado VSIX validado como ZIP. + [x] Comprobar que no queda ningún archivo/configuración TypeScript en fuente ni VSIX. + [x] Commit final de version-0.1.1. + [x] Publicar rama version-0.1.1 en GitHub. diff --git a/project requirements.txt b/project requirements.txt index 95fa5e9..4340764 100644 --- a/project requirements.txt +++ b/project requirements.txt @@ -1,30 +1,62 @@ -Funciones que DEBE tener - -La primera versión podría incluir: - -Crear, editar, probar y eliminar conexiones. -Guardar varias conexiones por motor. -Conectar y desconectar manualmente. -Navegador lateral con: -Bases de datos. -Esquemas. -Tablas. -Vistas. -Columnas. -Procedimientos. -Abrir un editor SQL. -Ejecutar: -Todo el documento. -Solo el texto seleccionado. -La sentencia donde está situado el cursor. -Consultas SELECT, INSERT, UPDATE, DELETE, ALTER, DROP, CREATE. -Creación y modificación de tablas. -PL/SQL de Oracle. -T-SQL de SQL Server. -Transacciones con COMMIT y ROLLBACK. -Resultados en tabla. -Exportación a CSV, JSON o Excel. -Historial de consultas. -Tiempo de ejecución y número de filas. -Cancelación de consultas largas. -Límite configurable de resultados, por ejemplo 100000 filas. \ No newline at end of file +SIMPLE DB 0.1.1 — REQUISITOS DE PRODUCTO + +Lenguaje y ejecución +- Todo el código de la extensión y todos los tests se escriben en JavaScript. +- No se usa TypeScript, tsconfig.json ni un paso de compilación TypeScript. +- F5 carga directamente src/extension.js. + +Motores obligatorios +- SQLite. +- PostgreSQL. +- MySQL. +- SQL Server. +- Oracle. + +Conexiones +- Crear, editar, probar y eliminar conexiones. +- Varias conexiones por motor y varios motores conectados a la vez. +- Contraseñas en VS Code SecretStorage. +- SSL/TLS/cifrado y timeouts según el motor. + +Exploración +- Bases de datos y esquemas cuando el motor los exponga. +- Tablas, vistas y columnas. +- Vistas materializadas donde existan. +- Procedimientos y funciones. +- Packages Oracle. +- Índices, triggers, secuencias y tipos cuando existan. +- Sinónimos en Oracle/SQL Server y eventos MySQL. +- Mostrar definición/DDL y preparar CREATE, ALTER y DROP. + +Editor y ejecución +- Documento SQL normal de VS Code vinculado a perfil/base/esquema. +- Ejecutar documento completo, selección o sentencia del cursor. +- SELECT, INSERT, UPDATE, DELETE, MERGE y cualquier DML soportado por el motor. +- CREATE, ALTER, DROP, TRUNCATE y demás DDL soportado por el motor. +- PostgreSQL dollar quotes, MySQL DELIMITER, SQL Server GO, Oracle PL/SQL y terminador /, triggers SQLite. +- Detener documento en el primer error y seleccionar el bloque fallido. + +Transacciones y cancelación +- Auto-commit fuera de una transacción explícita. +- BEGIN, COMMIT y ROLLBACK por editor. +- Conexión física reservada durante una transacción. +- ROLLBACK al cerrar editor/desconectar con transacción abierta. +- Cancelación por mecanismo nativo del driver. +- Timeout configurable por perfil. + +Resultados +- Tabla, tipos, NULL, varias tablas de resultados, filas recuperadas/afectadas y duración. +- Copiar celda, fila y selección. +- Exportar CSV, JSON y XLSX sin reejecutar la consulta. +- Historial local configurable. + +Política de filas +- simpleDb.maxRows = 0 por defecto: SIN LÍMITE. +- El usuario puede elegir un límite mayor que cero si lo necesita. +- simpleDb.resultPageSize controla páginas de almacenamiento/visualización, no el número total de filas. +- Simple DB NO inyecta TOP, LIMIT ni FETCH en el SQL del usuario. + +Protecciones +- Confirmación configurable para DROP/TRUNCATE. +- Aviso configurable para UPDATE/DELETE sin WHERE. +- Los scripts destructivos generados se abren en el editor; no se ejecutan automáticamente. diff --git a/src/adapters/baseAdapter.js b/src/adapters/baseAdapter.js new file mode 100644 index 0000000..dfca9af --- /dev/null +++ b/src/adapters/baseAdapter.js @@ -0,0 +1,121 @@ +'use strict'; + +class BaseAdapter { + constructor(profile, password) { + this.profile = { ...profile }; + this.password = password || ''; + this.transactions = new Map(); + this.activeExecutions = new Map(); + this.connected = false; + this.serverVersion = ''; + } + + isConnected() { + return this.connected; + } + + hasTransaction(sessionId) { + return this.transactions.has(sessionId); + } + + transactionCount() { + return this.transactions.size; + } + + executionCount() { + return this.activeExecutions.size; + } + + async rollbackAll() { + for (const sessionId of [...this.transactions.keys()]) { + try { + await this.rollback(sessionId); + } catch (_error) { + // El cierre posterior del pool/conexión libera también la transacción. + } + } + } + + async cancelAll() { + for (const executionId of [...this.activeExecutions.keys()]) { + try { + await this.cancel(executionId); + } catch (_error) { + // Se continúa con la limpieza del resto de recursos. + } + } + } + + quoteIdentifier(identifier) { + return `"${String(identifier).replaceAll('"', '""')}"`; + } + + quoteTable(_database, schema, table) { + return [schema, table] + .filter(Boolean) + .map((part) => this.quoteIdentifier(part)) + .join('.'); + } + + async listMaterializedViews() { + return []; + } + + async listIndexes() { + return []; + } + + async listTriggers() { + return []; + } + + async listSequences() { + return []; + } + + async listPackages() { + return []; + } + + async listTypes() { + return []; + } + + async listSynonyms() { + return []; + } + + async listEvents() { + return []; + } + + async getObjectDefinition() { + return null; + } +} + +function normalizeColumns(fields) { + return (fields || []).map((field, index) => ({ + name: String(field.name || field.columnName || `Column ${index + 1}`), + type: String(field.dataTypeID || field.type || field.dbTypeName || ''), + nullable: field.nullable, + })); +} + +async function emitRowsInChunks(sink, setIndex, rows, pageSize, maxRows) { + const hasLimit = Number(maxRows) > 0; + const available = hasLimit ? Math.min(rows.length, maxRows) : rows.length; + for (let offset = 0; offset < available; offset += pageSize) { + await sink.rows(setIndex, rows.slice(offset, Math.min(offset + pageSize, available))); + } + return { + rowCount: available, + truncated: hasLimit && rows.length > maxRows, + }; +} + +module.exports = { + BaseAdapter, + emitRowsInChunks, + normalizeColumns, +}; diff --git a/src/adapters/factory.js b/src/adapters/factory.js new file mode 100644 index 0000000..8e9b31f --- /dev/null +++ b/src/adapters/factory.js @@ -0,0 +1,28 @@ +'use strict'; + +const { MySqlAdapter } = require('./mysqlAdapter'); +const { OracleAdapter } = require('./oracleAdapter'); +const { PostgreSqlAdapter } = require('./postgresqlAdapter'); +const { SqliteAdapter } = require('./sqliteAdapter'); +const { SqlServerAdapter } = require('./sqlServerAdapter'); + +function createAdapter(profile, password) { + switch (profile.engine) { + case 'sqlite': + return new SqliteAdapter(profile, password); + case 'postgresql': + return new PostgreSqlAdapter(profile, password); + case 'mysql': + return new MySqlAdapter(profile, password); + case 'sqlserver': + return new SqlServerAdapter(profile, password); + case 'oracle': + return new OracleAdapter(profile, password); + default: + throw new Error(`Motor no soportado: ${profile.engine}`); + } +} + +module.exports = { + createAdapter, +}; diff --git a/src/adapters/mysqlAdapter.js b/src/adapters/mysqlAdapter.js new file mode 100644 index 0000000..4b63858 --- /dev/null +++ b/src/adapters/mysqlAdapter.js @@ -0,0 +1,498 @@ +'use strict'; + +const mysql = require('mysql2'); +const { BaseAdapter } = require('./baseAdapter'); +const { normalizeDatabaseError } = require('../core/errors'); + +function mysqlFieldType(field) { + const typeId = field?.columnType ?? field?.type; + return mysql.Types?.[typeId] || String(typeId ?? ''); +} + +function callbackPromise(register) { + return new Promise((resolve, reject) => { + register((error, ...values) => { + if (error) { + reject(error); + } else { + resolve(values.length <= 1 ? values[0] : values); + } + }); + }); +} + +function wrapMysqlDefinition(definition) { + if (!definition) return null; + const sql = String(definition).trim().replace(/;\s*$/, ''); + return `DELIMITER $$\n${sql}$$\nDELIMITER ;`; +} + +class MySqlAdapter extends BaseAdapter { + constructor(profile, password) { + super(profile, password); + this.pool = null; + } + + _config() { + return { + host: this.profile.host, + port: this.profile.port || 3306, + user: this.profile.user, + password: this.password, + database: this.profile.database || undefined, + connectTimeout: this.profile.connectTimeoutMs || 15000, + waitForConnections: true, + connectionLimit: 5, + maxIdle: 5, + idleTimeout: 30000, + enableKeepAlive: true, + multipleStatements: false, + supportBigNumbers: true, + bigNumberStrings: true, + dateStrings: false, + ssl: this.profile.ssl + ? { rejectUnauthorized: !this.profile.trustServerCertificate } + : undefined, + }; + } + + _getConnection() { + return callbackPromise((done) => this.pool.getConnection(done)); + } + + _query(target, statement, values = []) { + return callbackPromise((done) => target.query(statement, values, done)); + } + + _selectDatabase(connection, database) { + const target = database || this.profile.database; + if (!target) return Promise.resolve(); + return callbackPromise((done) => + connection.changeUser( + { + user: this.profile.user, + password: this.password, + database: target, + }, + done, + ), + ); + } + + async connect() { + try { + this.pool = mysql.createPool(this._config()); + const [rows] = await this._query(this.pool, 'SELECT VERSION() AS version'); + this.serverVersion = rows[0]?.version || 'MySQL'; + this.connected = true; + return this.serverVersion; + } catch (error) { + if (this.pool) { + await callbackPromise((done) => this.pool.end(done)).catch(() => {}); + this.pool = null; + } + throw normalizeDatabaseError(error, 'mysql'); + } + } + + async disconnect() { + await this.cancelAll(); + await this.rollbackAll(); + if (this.pool) { + await callbackPromise((done) => this.pool.end(done)); + this.pool = null; + } + this.connected = false; + } + + async begin(sessionId, context = {}) { + if (this.transactions.has(sessionId)) { + throw new Error('Esta sesión ya tiene una transacción MySQL activa.'); + } + const connection = await this._getConnection(); + try { + await this._selectDatabase(connection, context.database); + if (context.beginSql) { + await this._query(connection, context.beginSql); + } else { + await callbackPromise((done) => connection.beginTransaction(done)); + } + this.transactions.set(sessionId, connection); + } catch (error) { + connection.release(); + throw normalizeDatabaseError(error, 'mysql'); + } + } + + async commit(sessionId) { + const connection = this.transactions.get(sessionId); + if (!connection) { + throw new Error('No hay una transacción activa en esta sesión.'); + } + try { + await callbackPromise((done) => connection.commit(done)); + } finally { + this.transactions.delete(sessionId); + connection.release(); + } + } + + async rollback(sessionId) { + const connection = this.transactions.get(sessionId); + if (!connection) { + return; + } + try { + await callbackPromise((done) => connection.rollback(done)); + } finally { + this.transactions.delete(sessionId); + connection.release(); + } + } + + async execute(sessionId, sql, options) { + const transactionConnection = this.transactions.get(sessionId); + const connection = transactionConnection || (await this._getConnection()); + if (!transactionConnection) { + try { + await this._selectDatabase(connection, options.database); + } catch (error) { + connection.release(); + throw normalizeDatabaseError(error, 'mysql'); + } + } + const timeout = Math.max(0, Number(this.profile.queryTimeoutMs || 0)); + const query = connection.query({ + sql, + timeout: timeout || undefined, + rowsAsArray: true, + }); + this.activeExecutions.set(options.executionId, { + threadId: connection.threadId, + }); + + let databaseResultIndex = -1; + let rowSetCount = 0; + let rowsAffected = 0; + let queue = Promise.resolve(); + const setByDatabaseIndex = new Map(); + const buffers = new Map(); + const seenRows = new Map(); + + const flush = (setIndex, useBackpressure) => { + const buffer = buffers.get(setIndex) || []; + if (!buffer.length) { + return; + } + buffers.set(setIndex, []); + if (useBackpressure) { + connection.pause(); + } + queue = queue + .then(() => options.sink.rows(setIndex, buffer)) + .finally(() => { + if (useBackpressure) { + connection.resume(); + } + }); + }; + + query.on('fields', (fields) => { + databaseResultIndex += 1; + if (!Array.isArray(fields)) { + return; + } + const setIndex = rowSetCount; + rowSetCount += 1; + setByDatabaseIndex.set(databaseResultIndex, setIndex); + buffers.set(setIndex, []); + seenRows.set(setIndex, 0); + const columns = fields.map((field, index) => ({ + name: field.name || `Column ${index + 1}`, + type: mysqlFieldType(field), + })); + queue = queue.then(() => options.sink.start(setIndex, columns)); + }); + + query.on('result', (row, resultIndex) => { + if (!Array.isArray(row)) { + rowsAffected += Number(row?.affectedRows || row?.changedRows || 0); + return; + } + + const databaseIndex = Number.isInteger(resultIndex) + ? resultIndex + : databaseResultIndex; + const setIndex = setByDatabaseIndex.get(databaseIndex); + if (setIndex === undefined) { + return; + } + + const seen = (seenRows.get(setIndex) || 0) + 1; + seenRows.set(setIndex, seen); + if (options.maxRows > 0 && seen > options.maxRows) { + return; + } + const buffer = buffers.get(setIndex); + buffer.push(row); + if (buffer.length >= options.pageSize) { + flush(setIndex, true); + } + }); + + try { + await new Promise((resolve, reject) => { + let settled = false; + query.once('error', (error) => { + if (!settled) { + settled = true; + reject(error); + } + }); + query.once('end', () => { + if (!settled) { + settled = true; + resolve(); + } + }); + }); + + for (let setIndex = 0; setIndex < rowSetCount; setIndex += 1) { + flush(setIndex, false); + } + await queue; + + let rowCount = 0; + let truncated = false; + for (let setIndex = 0; setIndex < rowSetCount; setIndex += 1) { + const seen = seenRows.get(setIndex) || 0; + const visibleRows = options.maxRows > 0 ? Math.min(seen, options.maxRows) : seen; + const setTruncated = options.maxRows > 0 && seen > options.maxRows; + rowCount += visibleRows; + truncated ||= setTruncated; + await options.sink.end(setIndex, { + rowCount: visibleRows, + truncated: setTruncated, + }); + } + + return { + command: 'MySQL', + rowCount, + rowsAffected, + resultSetCount: rowSetCount, + truncated, + }; + } catch (error) { + throw normalizeDatabaseError(error, 'mysql'); + } finally { + this.activeExecutions.delete(options.executionId); + if (!transactionConnection) { + connection.release(); + } + } + } + + async cancel(executionId) { + const threadId = Number(this.activeExecutions.get(executionId)?.threadId); + if (!Number.isSafeInteger(threadId) || threadId <= 0 || !this.pool) { + return false; + } + const controlConnection = mysql.createConnection(this._config()); + try { + await callbackPromise((done) => controlConnection.connect(done)); + await this._query(controlConnection, `KILL QUERY ${threadId}`); + return true; + } catch (_error) { + return false; + } finally { + await callbackPromise((done) => controlConnection.end(done)).catch(() => { + controlConnection.destroy(); + }); + } + } + + async listDatabases() { + const [rows] = await this._query( + this.pool, + `SELECT schema_name AS name + FROM information_schema.schemata + WHERE schema_name NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') + ORDER BY schema_name`, + ); + return rows; + } + + async listSchemas(database) { + return [{ name: database }]; + } + + async listTables(database) { + const [rows] = await this._query( + this.pool, + `SELECT table_name AS name + FROM information_schema.tables + WHERE table_schema = ? AND table_type = 'BASE TABLE' + ORDER BY table_name`, + [database], + ); + return rows; + } + + async listViews(database) { + const [rows] = await this._query( + this.pool, + `SELECT table_name AS name + FROM information_schema.views + WHERE table_schema = ? + ORDER BY table_name`, + [database], + ); + return rows; + } + + async listColumns(database, _schema, objectName) { + const [rows] = await this._query( + this.pool, + `SELECT column_name AS name, + column_type AS type, + is_nullable = 'YES' AS nullable, + ordinal_position AS position + FROM information_schema.columns + WHERE table_schema = ? AND table_name = ? + ORDER BY ordinal_position`, + [database, objectName], + ); + return rows; + } + + async listProcedures(database) { + const [rows] = await this._query( + this.pool, + `SELECT routine_name AS name, routine_type AS type + FROM information_schema.routines + WHERE routine_schema = ? + ORDER BY routine_name`, + [database], + ); + return rows; + } + + async listIndexes(database) { + const [rows] = await this._query( + this.pool, + `SELECT DISTINCT index_name AS name, table_name AS tableName + FROM information_schema.statistics + WHERE table_schema = ? + ORDER BY table_name, index_name`, + [database], + ); + return rows; + } + + async listTriggers(database) { + const [rows] = await this._query( + this.pool, + `SELECT trigger_name AS name, event_object_table AS tableName + FROM information_schema.triggers + WHERE trigger_schema = ? + ORDER BY trigger_name`, + [database], + ); + return rows; + } + + async listEvents(database) { + const [rows] = await this._query( + this.pool, + `SELECT event_name AS name + FROM information_schema.events + WHERE event_schema = ? + ORDER BY event_name`, + [database], + ); + return rows; + } + + _createDefinitionFromRow(row) { + if (!row) return null; + const key = Object.keys(row).find((name) => /^Create\s/i.test(name)); + return key ? String(row[key]) : null; + } + + async getObjectDefinition(database, _schema, objectName, objectType, metadata = {}) { + const qualified = `${this.quoteIdentifier(database)}.${this.quoteIdentifier(objectName)}`; + if (objectType === 'table') { + const [rows] = await this._query(this.pool, `SHOW CREATE TABLE ${qualified}`); + return this._createDefinitionFromRow(rows[0]); + } + if (objectType === 'view') { + const [rows] = await this._query(this.pool, `SHOW CREATE VIEW ${qualified}`); + return this._createDefinitionFromRow(rows[0]); + } + if (objectType === 'procedure') { + const [routines] = await this._query( + this.pool, + `SELECT routine_type AS type FROM information_schema.routines + WHERE routine_schema = ? AND routine_name = ? LIMIT 1`, + [database, objectName], + ); + const type = String(routines[0]?.type || metadata.type || 'PROCEDURE').toUpperCase(); + const keyword = type === 'FUNCTION' ? 'FUNCTION' : 'PROCEDURE'; + const [rows] = await this._query(this.pool, `SHOW CREATE ${keyword} ${qualified}`); + return wrapMysqlDefinition(this._createDefinitionFromRow(rows[0])); + } + if (objectType === 'trigger') { + const [rows] = await this._query(this.pool, `SHOW CREATE TRIGGER ${qualified}`); + return wrapMysqlDefinition(this._createDefinitionFromRow(rows[0])); + } + if (objectType === 'event') { + const [rows] = await this._query(this.pool, `SHOW CREATE EVENT ${qualified}`); + return wrapMysqlDefinition(this._createDefinitionFromRow(rows[0])); + } + if (objectType === 'index' && metadata.tableName) { + const table = `${this.quoteIdentifier(database)}.${this.quoteIdentifier(metadata.tableName)}`; + const [rows] = await this._query(this.pool, `SHOW INDEX FROM ${table}`); + const indexRows = rows + .filter((row) => String(row.Key_name) === String(objectName)) + .sort((left, right) => Number(left.Seq_in_index) - Number(right.Seq_in_index)); + if (!indexRows.length) return null; + const first = indexRows[0]; + const columns = indexRows.map((row) => { + let value = row.Column_name + ? this.quoteIdentifier(row.Column_name) + : `(${row.Expression || '/* expression */'})`; + if (row.Sub_part) value += `(${row.Sub_part})`; + if (row.Collation === 'D') value += ' DESC'; + return value; + }); + if (String(objectName).toUpperCase() === 'PRIMARY') { + return `ALTER TABLE ${table} ADD PRIMARY KEY (${columns.join(', ')});`; + } + const kind = String(first.Index_type || '').toUpperCase(); + const prefix = ['FULLTEXT', 'SPATIAL'].includes(kind) + ? ` ${kind}` + : first.Non_unique === 0 + ? ' UNIQUE' + : ''; + return `CREATE${prefix} INDEX ${this.quoteIdentifier(objectName)} ON ${table} (${columns.join(', ')});`; + } + return null; + } + + quoteIdentifier(identifier) { + return `\`${String(identifier).replaceAll('`', '``')}\``; + } + + quoteTable(database, _schema, table) { + return [database, table] + .filter(Boolean) + .map((part) => this.quoteIdentifier(part)) + .join('.'); + } +} + +module.exports = { + MySqlAdapter, + wrapMysqlDefinition, +}; diff --git a/src/adapters/oracleAdapter.js b/src/adapters/oracleAdapter.js new file mode 100644 index 0000000..e02d368 --- /dev/null +++ b/src/adapters/oracleAdapter.js @@ -0,0 +1,462 @@ +'use strict'; + +const oracledb = require('oracledb'); +const { BaseAdapter } = require('./baseAdapter'); +const { normalizeDatabaseError } = require('../core/errors'); +const { looksLikeRowQuery } = require('../sql/safety'); + +// NUMBER como string evita perder precisión al cruzar el límite seguro de +// enteros de JavaScript; ResultStore conserva después el valor textual exacto. +const stringFetchTypes = [ + oracledb.DB_TYPE_NUMBER, + oracledb.DB_TYPE_CLOB, + oracledb.DB_TYPE_NCLOB, +].filter(Boolean); +if (stringFetchTypes.length > 0) { + oracledb.fetchAsString = stringFetchTypes; +} +if (oracledb.DB_TYPE_BLOB) { + oracledb.fetchAsBuffer = [oracledb.DB_TYPE_BLOB]; +} + +class OracleAdapter extends BaseAdapter { + constructor(profile, password) { + super(profile, password); + this.pool = null; + this.sessionUser = ''; + } + + _connectString() { + if (this.profile.connectString) { + return this.profile.connectString; + } + const service = this.profile.serviceName || this.profile.database; + return `${this.profile.host}:${this.profile.port || 1521}/${service}`; + } + + _poolConfig() { + const timeoutSeconds = Math.max( + 0, + Math.ceil(Number(this.profile.connectTimeoutMs || 15000) / 1000), + ); + return { + user: this.profile.user, + password: this.password, + connectString: this._connectString(), + connectTimeout: timeoutSeconds, + poolMin: 0, + poolMax: 5, + poolIncrement: 1, + poolTimeout: 60, + queueTimeout: this.profile.connectTimeoutMs || 15000, + }; + } + + _prepareConnection(connection) { + connection.callTimeout = Math.max(0, Number(this.profile.queryTimeoutMs || 0)); + return connection; + } + + async connect() { + try { + this.pool = await oracledb.createPool(this._poolConfig()); + const connection = this._prepareConnection(await this.pool.getConnection()); + try { + this.serverVersion = connection.oracleServerVersionString || 'Oracle'; + const identity = await connection.execute( + `SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') AS "user" FROM dual`, + [], + { outFormat: oracledb.OUT_FORMAT_OBJECT }, + ); + this.sessionUser = String(identity.rows?.[0]?.user || this.profile.user || ''); + } finally { + await connection.close(); + } + this.connected = true; + return this.serverVersion; + } catch (error) { + if (this.pool) { + await this.pool.close(0).catch(() => {}); + this.pool = null; + } + throw normalizeDatabaseError(error, 'oracle'); + } + } + + async disconnect() { + await this.cancelAll(); + await this.rollbackAll(); + if (this.pool) { + await this.pool.close(2); + this.pool = null; + } + this.connected = false; + } + + async _setCurrentSchema(connection, schema) { + const target = schema || this.sessionUser; + if (!target) return; + await connection.execute( + `ALTER SESSION SET CURRENT_SCHEMA = ${this.quoteIdentifier(target)}`, + ); + } + + async begin(sessionId, context = {}) { + if (this.transactions.has(sessionId)) { + throw new Error('Esta sesión ya tiene una transacción Oracle activa.'); + } + let connection; + try { + connection = this._prepareConnection(await this.pool.getConnection()); + await this._setCurrentSchema(connection, context.schema); + this.transactions.set(sessionId, connection); + } catch (error) { + await connection?.close().catch(() => {}); + throw normalizeDatabaseError(error, 'oracle'); + } + } + + async commit(sessionId) { + const connection = this.transactions.get(sessionId); + if (!connection) { + throw new Error('No hay una transacción activa en esta sesión.'); + } + try { + await connection.commit(); + } finally { + this.transactions.delete(sessionId); + await connection.close(); + } + } + + async rollback(sessionId) { + const connection = this.transactions.get(sessionId); + if (!connection) { + return; + } + try { + await connection.rollback(); + } finally { + this.transactions.delete(sessionId); + await connection.close(); + } + } + + async _executeRows(connection, sql, options) { + let resultSet; + try { + const result = await connection.execute(sql, [], { + outFormat: oracledb.OUT_FORMAT_ARRAY, + resultSet: true, + fetchArraySize: options.pageSize, + autoCommit: false, + }); + resultSet = result.resultSet; + const columns = (result.metaData || []).map((field, index) => ({ + name: field.name || `Column ${index + 1}`, + type: field.dbTypeName || String(field.dbType || ''), + nullable: field.nullable, + })); + await options.sink.start(0, columns); + + let rowCount = 0; + let truncated = false; + const hasLimit = Number(options.maxRows) > 0; + while (!hasLimit || rowCount < options.maxRows) { + const room = hasLimit ? options.maxRows - rowCount : options.pageSize; + const requested = Math.max(1, Math.min(options.pageSize, room)); + const rows = await resultSet.getRows(requested); + if (rows.length === 0) { + break; + } + await options.sink.rows(0, rows); + rowCount += rows.length; + if (rows.length < requested) { + break; + } + } + + if (hasLimit && rowCount >= options.maxRows) { + truncated = (await resultSet.getRows(1)).length > 0; + } + await options.sink.end(0, { rowCount, truncated }); + return { rowCount, rowsAffected: 0, resultSetCount: 1, truncated }; + } finally { + if (resultSet) { + await resultSet.close().catch(() => {}); + } + } + } + + async _executeRegular(connection, sql, autoCommit) { + const result = await connection.execute(sql, [], { + outFormat: oracledb.OUT_FORMAT_ARRAY, + autoCommit, + }); + return { + command: 'SQL/PLSQL', + rowCount: 0, + rowsAffected: Number(result.rowsAffected || 0), + resultSetCount: 0, + truncated: false, + }; + } + + async execute(sessionId, sql, options) { + const transactionConnection = this.transactions.get(sessionId); + const connection = + transactionConnection || this._prepareConnection(await this.pool.getConnection()); + + try { + if (!transactionConnection) { + await this._setCurrentSchema(connection, options.schema); + } + this.activeExecutions.set(options.executionId, { connection }); + if (looksLikeRowQuery(sql, 'oracle')) { + return await this._executeRows(connection, sql, options); + } + return await this._executeRegular(connection, sql, !transactionConnection); + } catch (error) { + throw normalizeDatabaseError(error, 'oracle'); + } finally { + this.activeExecutions.delete(options.executionId); + if (!transactionConnection) { + await connection.close().catch(() => {}); + } + } + } + + async cancel(executionId) { + const connection = this.activeExecutions.get(executionId)?.connection; + if (!connection) { + return false; + } + try { + await connection.break(); + return true; + } catch (_error) { + return false; + } + } + + async _query(sql, binds = {}) { + const connection = this._prepareConnection(await this.pool.getConnection()); + try { + const result = await connection.execute(sql, binds, { + outFormat: oracledb.OUT_FORMAT_OBJECT, + }); + return result.rows || []; + } finally { + await connection.close(); + } + } + + async listDatabases() { + const rows = await this._query( + `SELECT SYS_CONTEXT('USERENV', 'DB_NAME') AS "name" FROM dual`, + ); + return rows.length ? rows : [{ name: this.profile.serviceName || this.profile.database }]; + } + + async listSchemas() { + return this._query( + `SELECT username AS "name" FROM all_users ORDER BY username`, + ); + } + + async listTables(_database, schema) { + return this._query( + `SELECT table_name AS "name" + FROM all_tables + WHERE owner = :owner + ORDER BY table_name`, + { owner: schema }, + ); + } + + async listViews(_database, schema) { + return this._query( + `SELECT view_name AS "name" + FROM all_views + WHERE owner = :owner + ORDER BY view_name`, + { owner: schema }, + ); + } + + async listColumns(_database, schema, objectName) { + return this._query( + `SELECT column_name AS "name", + data_type AS "type", + CASE nullable WHEN 'Y' THEN 1 ELSE 0 END AS "nullable", + column_id AS "position" + FROM all_tab_columns + WHERE owner = :owner AND table_name = :object_name + ORDER BY column_id`, + { owner: schema, object_name: objectName }, + ); + } + + async listProcedures(_database, schema) { + return this._query( + `SELECT object_name AS "name", object_type AS "type" + FROM all_objects + WHERE owner = :owner + AND object_type IN ('PROCEDURE', 'FUNCTION') + ORDER BY object_name`, + { owner: schema }, + ); + } + + async listMaterializedViews(_database, schema) { + return this._query( + `SELECT mview_name AS "name" + FROM all_mviews + WHERE owner = :owner + ORDER BY mview_name`, + { owner: schema }, + ); + } + + async listIndexes(_database, schema) { + return this._query( + `SELECT index_name AS "name", table_name AS "tableName", uniqueness AS "type" + FROM all_indexes + WHERE owner = :owner + ORDER BY table_name, index_name`, + { owner: schema }, + ); + } + + async listTriggers(_database, schema) { + return this._query( + `SELECT trigger_name AS "name", table_name AS "tableName", status AS "type" + FROM all_triggers + WHERE owner = :owner + ORDER BY trigger_name`, + { owner: schema }, + ); + } + + async listSequences(_database, schema) { + return this._query( + `SELECT sequence_name AS "name" + FROM all_sequences + WHERE sequence_owner = :owner + ORDER BY sequence_name`, + { owner: schema }, + ); + } + + async listPackages(_database, schema) { + return this._query( + `SELECT object_name AS "name", status AS "type" + FROM all_objects + WHERE owner = :owner AND object_type = 'PACKAGE' + ORDER BY object_name`, + { owner: schema }, + ); + } + + async listTypes(_database, schema) { + return this._query( + `SELECT type_name AS "name", typecode AS "type" + FROM all_types + WHERE owner = :owner + ORDER BY type_name`, + { owner: schema }, + ); + } + + async listSynonyms(_database, schema) { + return this._query( + `SELECT synonym_name AS "name", + table_owner || '.' || table_name AS "target" + FROM all_synonyms + WHERE owner = :owner + ORDER BY synonym_name`, + { owner: schema }, + ); + } + + async _sourceDefinition(schema, objectName, sourceTypes) { + const rows = await this._query( + `SELECT type AS "type", text AS "text", line AS "line" + FROM all_source + WHERE owner = :owner + AND name = :object_name + AND type IN (${sourceTypes.map((_type, index) => `:type${index}`).join(', ')}) + ORDER BY DECODE(type, 'PACKAGE', 1, 'PACKAGE BODY', 2, 'TYPE', 1, 'TYPE BODY', 2, 1), line`, + Object.fromEntries([ + ['owner', schema], + ['object_name', objectName], + ...sourceTypes.map((type, index) => [`type${index}`, type]), + ]), + ); + if (!rows.length) return null; + + const groups = new Map(); + for (const row of rows) { + if (!groups.has(row.type)) groups.set(row.type, []); + groups.get(row.type).push(row.text); + } + return [...groups.values()] + .map((lines) => `CREATE OR REPLACE ${lines.join('').trim()}\n/`) + .join('\n\n'); + } + + async getObjectDefinition(_database, schema, objectName, objectType) { + if (objectType === 'procedure') { + return this._sourceDefinition(schema, objectName, ['PROCEDURE', 'FUNCTION']); + } + if (objectType === 'package') { + return this._sourceDefinition(schema, objectName, ['PACKAGE', 'PACKAGE BODY']); + } + if (objectType === 'trigger') { + return this._sourceDefinition(schema, objectName, ['TRIGGER']); + } + if (objectType === 'type') { + return this._sourceDefinition(schema, objectName, ['TYPE', 'TYPE BODY']); + } + + const metadataTypes = { + table: 'TABLE', + view: 'VIEW', + materializedView: 'MATERIALIZED_VIEW', + index: 'INDEX', + sequence: 'SEQUENCE', + synonym: 'SYNONYM', + }; + const metadataType = metadataTypes[objectType]; + if (!metadataType) return null; + try { + const rows = await this._query( + `SELECT DBMS_METADATA.GET_DDL(:metadata_type, :object_name, :owner) AS "definition" + FROM dual`, + { + metadata_type: metadataType, + object_name: objectName, + owner: schema, + }, + ); + return rows[0]?.definition ? String(rows[0].definition) : null; + } catch (_error) { + if (objectType === 'view') { + const rows = await this._query( + `SELECT text AS "definition" FROM all_views + WHERE owner = :owner AND view_name = :object_name`, + { owner: schema, object_name: objectName }, + ); + const definition = rows[0]?.definition; + return definition + ? `CREATE OR REPLACE VIEW ${this.quoteTable(null, schema, objectName)} AS\n${definition}` + : null; + } + return null; + } + } +} + +module.exports = { + OracleAdapter, +}; diff --git a/src/adapters/postgresqlAdapter.js b/src/adapters/postgresqlAdapter.js new file mode 100644 index 0000000..d80b4e6 --- /dev/null +++ b/src/adapters/postgresqlAdapter.js @@ -0,0 +1,661 @@ +'use strict'; + +const { Client, Pool } = require('pg'); +const Cursor = require('pg-cursor'); +const { BaseAdapter, emitRowsInChunks } = require('./baseAdapter'); +const { normalizeDatabaseError } = require('../core/errors'); +const { looksLikeRowQuery } = require('../sql/safety'); + +function readCursor(cursor, rowCount) { + return new Promise((resolve, reject) => { + cursor.read(rowCount, (error, rows, result) => { + if (error) reject(error); + else resolve({ rows, result }); + }); + }); +} + +class PostgreSqlAdapter extends BaseAdapter { + constructor(profile, password) { + super(profile, password); + this.pool = null; + } + + _connectionConfig(database = this.profile.database || 'postgres') { + const timeout = Math.max(0, Number(this.profile.queryTimeoutMs || 0)); + return { + host: this.profile.host, + port: this.profile.port || 5432, + user: this.profile.user, + password: this.password, + database, + connectionTimeoutMillis: this.profile.connectTimeoutMs || 15000, + application_name: 'simple-db-vscode', + statement_timeout: timeout || undefined, + query_timeout: timeout || undefined, + ssl: this.profile.ssl + ? { rejectUnauthorized: !this.profile.trustServerCertificate } + : false, + }; + } + + async connect() { + try { + this.pool = new Pool({ + ...this._connectionConfig(), + max: 5, + idleTimeoutMillis: 30000, + }); + const result = await this.pool.query('SHOW server_version'); + this.serverVersion = result.rows[0]?.server_version || 'PostgreSQL'; + this.connected = true; + return this.serverVersion; + } catch (error) { + if (this.pool) { + await this.pool.end().catch(() => {}); + this.pool = null; + } + throw normalizeDatabaseError(error, 'postgresql'); + } + } + + async disconnect() { + await this.cancelAll(); + await this.rollbackAll(); + if (this.pool) { + await this.pool.end(); + this.pool = null; + } + this.connected = false; + } + + async _acquireClient(database) { + const target = database || this.profile.database || 'postgres'; + const primary = this.profile.database || 'postgres'; + if (target === primary) { + const client = await this.pool.connect(); + return { client, database: target, release: () => client.release() }; + } + const client = new Client(this._connectionConfig(target)); + await client.connect(); + return { client, database: target, release: () => client.end() }; + } + + async _setSearchPath(client, schema, local = false) { + if (!schema) return; + await client.query( + `${local ? 'SET LOCAL' : 'SET'} search_path TO ${this.quoteIdentifier(schema)}`, + ); + } + + async begin(sessionId, context = {}) { + if (this.transactions.has(sessionId)) { + throw new Error('Esta sesión ya tiene una transacción PostgreSQL activa.'); + } + const acquired = await this._acquireClient(context.database); + try { + await acquired.client.query(context.beginSql || 'BEGIN'); + await this._setSearchPath(acquired.client, context.schema, true); + this.transactions.set(sessionId, acquired); + } catch (error) { + await acquired.client.query('ROLLBACK').catch(() => {}); + await acquired.release(); + throw normalizeDatabaseError(error, 'postgresql'); + } + } + + async commit(sessionId) { + const transaction = this.transactions.get(sessionId); + if (!transaction) { + throw new Error('No hay una transacción activa en esta sesión.'); + } + try { + await transaction.client.query('COMMIT'); + } finally { + this.transactions.delete(sessionId); + await transaction.release(); + } + } + + async rollback(sessionId) { + const transaction = this.transactions.get(sessionId); + if (!transaction) { + return; + } + try { + await transaction.client.query('ROLLBACK'); + } finally { + this.transactions.delete(sessionId); + await transaction.release(); + } + } + + async _executeCursor(client, sql, options) { + const cursor = client.query(new Cursor(sql, [], { rowMode: 'array' })); + const setIndex = 0; + let started = false; + let rowCount = 0; + let truncated = false; + const hasLimit = Number(options.maxRows) > 0; + + try { + while (true) { + const room = hasLimit + ? Math.max(0, options.maxRows - rowCount) + : options.pageSize; + const requested = hasLimit + ? Math.max(1, Math.min(options.pageSize, room + 1)) + : options.pageSize; + const read = await readCursor(cursor, requested); + const rows = read.rows; + const fields = read.result?.fields || []; + + if (!started) { + await options.sink.start( + setIndex, + fields.map((field, index) => ({ + name: field.name || `Column ${index + 1}`, + type: String(field.dataTypeID || ''), + })), + ); + started = true; + } + + if (rows.length === 0) { + break; + } + + const accepted = hasLimit ? rows.slice(0, room) : rows; + if (accepted.length > 0) { + await options.sink.rows(setIndex, accepted); + rowCount += accepted.length; + } + if (hasLimit && rows.length > accepted.length) { + truncated = true; + break; + } + if (hasLimit && rowCount >= options.maxRows) { + const extra = await readCursor(cursor, 1); + truncated = extra.rows.length > 0; + break; + } + } + } finally { + await cursor.close().catch(() => {}); + } + + await options.sink.end(setIndex, { rowCount, truncated }); + return { rowCount, rowsAffected: 0, resultSetCount: 1, truncated }; + } + + async _executeRegular(client, sql, options) { + const result = await client.query({ text: sql, rowMode: 'array' }); + let resultSetCount = 0; + let rowCount = 0; + let truncated = false; + + if (result.fields?.length) { + resultSetCount = 1; + const columns = result.fields.map((field, index) => ({ + name: field.name || `Column ${index + 1}`, + type: String(field.dataTypeID || ''), + })); + await options.sink.start(0, columns); + const emitted = await emitRowsInChunks( + options.sink, + 0, + result.rows || [], + options.pageSize, + options.maxRows, + ); + rowCount = emitted.rowCount; + truncated = emitted.truncated; + await options.sink.end(0, { rowCount, truncated }); + } + + return { + command: result.command || 'SQL', + rowCount, + rowsAffected: Number(result.rowCount || 0), + resultSetCount, + truncated, + }; + } + + async execute(sessionId, sql, options) { + const transaction = this.transactions.get(sessionId); + const acquired = transaction || (await this._acquireClient(options.database)); + const client = acquired.client; + this.activeExecutions.set(options.executionId, { + client, + processId: client.processID, + }); + + try { + if (!transaction) { + await this._setSearchPath(client, options.schema, false); + } + if (looksLikeRowQuery(sql, 'postgresql')) { + return await this._executeCursor(client, sql, options); + } + return await this._executeRegular(client, sql, options); + } catch (error) { + throw normalizeDatabaseError(error, 'postgresql'); + } finally { + this.activeExecutions.delete(options.executionId); + if (!transaction) { + if (options.schema && acquired.database === (this.profile.database || 'postgres')) { + await client.query('RESET search_path').catch(() => {}); + } + await acquired.release(); + } + } + } + + async cancel(executionId) { + const active = this.activeExecutions.get(executionId); + if (!active?.processId || !this.pool) { + return false; + } + const cancelClient = new Client(this._connectionConfig()); + try { + await cancelClient.connect(); + const result = await cancelClient.query('SELECT pg_cancel_backend($1) AS cancelled', [ + active.processId, + ]); + return result.rows[0]?.cancelled === true; + } catch (_error) { + return false; + } finally { + await cancelClient.end().catch(() => {}); + } + } + + async _metadataQuery(database, text, values = []) { + const target = database || this.profile.database || 'postgres'; + if (target === (this.profile.database || 'postgres')) { + return this.pool.query(text, values); + } + + const client = new Client(this._connectionConfig(target)); + await client.connect(); + try { + return await client.query(text, values); + } finally { + await client.end(); + } + } + + async listDatabases() { + const result = await this.pool.query( + `SELECT datname AS name + FROM pg_database + WHERE datallowconn AND NOT datistemplate + AND has_database_privilege(datname, 'CONNECT') + ORDER BY datname`, + ); + return result.rows; + } + + async listSchemas(database) { + const result = await this._metadataQuery( + database, + `SELECT schema_name AS name + FROM information_schema.schemata + WHERE schema_name <> 'information_schema' + AND schema_name NOT LIKE 'pg_%' + ORDER BY schema_name`, + ); + return result.rows; + } + + async listTables(database, schema) { + const result = await this._metadataQuery( + database, + `SELECT table_name AS name + FROM information_schema.tables + WHERE table_schema = $1 AND table_type = 'BASE TABLE' + ORDER BY table_name`, + [schema], + ); + return result.rows; + } + + async listViews(database, schema) { + const result = await this._metadataQuery( + database, + `SELECT table_name AS name + FROM information_schema.views + WHERE table_schema = $1 + ORDER BY table_name`, + [schema], + ); + return result.rows; + } + + async listColumns(database, schema, objectName) { + const result = await this._metadataQuery( + database, + `SELECT a.attname AS name, + pg_catalog.format_type(a.atttypid, a.atttypmod) AS type, + NOT a.attnotnull AS nullable, + a.attnum AS position + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relname = $2 + AND c.relkind IN ('r', 'p', 'v', 'm', 'f') + AND a.attnum > 0 AND NOT a.attisdropped + ORDER BY a.attnum`, + [schema, objectName], + ); + return result.rows; + } + + async listProcedures(database, schema) { + const result = await this._metadataQuery( + database, + `SELECT p.proname AS name, + CASE p.prokind WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS type, + pg_get_function_identity_arguments(p.oid) AS signature + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = $1 AND p.prokind IN ('f', 'p') + ORDER BY p.proname`, + [schema], + ); + return result.rows; + } + + async listMaterializedViews(database, schema) { + const result = await this._metadataQuery( + database, + `SELECT matviewname AS name + FROM pg_matviews + WHERE schemaname = $1 + ORDER BY matviewname`, + [schema], + ); + return result.rows; + } + + async listIndexes(database, schema) { + const result = await this._metadataQuery( + database, + `SELECT idx.relname AS name, + tbl.relname AS "tableName", + con.conname AS "constraintName", + con.contype AS "constraintType" + FROM pg_class idx + JOIN pg_namespace ns ON ns.oid = idx.relnamespace + JOIN pg_index ix ON ix.indexrelid = idx.oid + JOIN pg_class tbl ON tbl.oid = ix.indrelid + LEFT JOIN pg_constraint con ON con.conindid = idx.oid + WHERE ns.nspname = $1 AND idx.relkind IN ('i', 'I') + ORDER BY tbl.relname, idx.relname`, + [schema], + ); + return result.rows; + } + + async listTriggers(database, schema) { + const result = await this._metadataQuery( + database, + `SELECT DISTINCT trigger_name AS name, event_object_table AS "tableName" + FROM information_schema.triggers + WHERE trigger_schema = $1 + ORDER BY trigger_name`, + [schema], + ); + return result.rows; + } + + async listSequences(database, schema) { + const result = await this._metadataQuery( + database, + `SELECT sequence_name AS name + FROM information_schema.sequences + WHERE sequence_schema = $1 + ORDER BY sequence_name`, + [schema], + ); + return result.rows; + } + + async listTypes(database, schema) { + const result = await this._metadataQuery( + database, + `SELECT t.typname AS name, + CASE t.typtype WHEN 'e' THEN 'ENUM' WHEN 'd' THEN 'DOMAIN' ELSE 'TYPE' END AS type + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = $1 + AND ( + t.typtype IN ('e', 'd') + OR (t.typtype = 'c' AND EXISTS ( + SELECT 1 FROM pg_class c WHERE c.oid = t.typrelid AND c.relkind = 'c' + )) + ) + ORDER BY t.typname`, + [schema], + ); + return result.rows; + } + + async _tableDefinition(database, schema, objectName) { + const columns = await this._metadataQuery( + database, + `SELECT a.attname AS name, + pg_catalog.format_type(a.atttypid, a.atttypmod) AS type, + pg_get_expr(ad.adbin, ad.adrelid) AS default_value, + a.attnotnull, + a.attidentity, + a.attgenerated + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum + WHERE n.nspname = $1 AND c.relname = $2 + AND a.attnum > 0 AND NOT a.attisdropped + ORDER BY a.attnum`, + [schema, objectName], + ); + if (!columns.rows.length) { + return null; + } + const constraints = await this._metadataQuery( + database, + `SELECT con.conname AS name, pg_get_constraintdef(con.oid, true) AS definition + FROM pg_constraint con + JOIN pg_class c ON c.oid = con.conrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relname = $2 + ORDER BY con.conname`, + [schema, objectName], + ); + + const lines = columns.rows.map((column) => { + let definition = `${this.quoteIdentifier(column.name)} ${column.type}`; + if (column.attidentity === 'a') { + definition += ' GENERATED ALWAYS AS IDENTITY'; + } else if (column.attidentity === 'd') { + definition += ' GENERATED BY DEFAULT AS IDENTITY'; + } else if (column.attgenerated === 's' && column.default_value) { + definition += ` GENERATED ALWAYS AS (${column.default_value}) STORED`; + } else if (column.default_value) { + definition += ` DEFAULT ${column.default_value}`; + } + if (column.attnotnull) { + definition += ' NOT NULL'; + } + return definition; + }); + for (const constraint of constraints.rows) { + lines.push( + `CONSTRAINT ${this.quoteIdentifier(constraint.name)} ${constraint.definition}`, + ); + } + const qualified = this.quoteTable(database, schema, objectName); + return `CREATE TABLE ${qualified} (\n ${lines.join(',\n ')}\n);`; + } + + async _typeDefinition(database, schema, objectName) { + const info = await this._metadataQuery( + database, + `SELECT t.oid, t.typtype, + pg_catalog.format_type(t.typbasetype, t.typtypmod) AS base_type, + t.typnotnull, t.typdefault + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = $1 AND t.typname = $2 + LIMIT 1`, + [schema, objectName], + ); + const type = info.rows[0]; + if (!type) return null; + const qualified = this.quoteTable(database, schema, objectName); + + if (type.typtype === 'e') { + const labels = await this._metadataQuery( + database, + `SELECT e.enumlabel + FROM pg_enum e + WHERE e.enumtypid = $1 + ORDER BY e.enumsortorder`, + [type.oid], + ); + const values = labels.rows + .map((row) => `'${String(row.enumlabel).replaceAll("'", "''")}'`) + .join(', '); + return `CREATE TYPE ${qualified} AS ENUM (${values});`; + } + + if (type.typtype === 'd') { + const constraints = await this._metadataQuery( + database, + `SELECT pg_get_constraintdef(c.oid, true) AS definition + FROM pg_constraint c + WHERE c.contypid = $1 + ORDER BY c.conname`, + [type.oid], + ); + const lines = [`CREATE DOMAIN ${qualified} AS ${type.base_type}`]; + if (type.typdefault !== null && type.typdefault !== undefined) { + lines.push(`DEFAULT ${type.typdefault}`); + } + if (type.typnotnull) lines.push('NOT NULL'); + lines.push(...constraints.rows.map((row) => row.definition).filter(Boolean)); + return `${lines.join('\n ')};`; + } + + if (type.typtype === 'c') { + const attributes = await this._metadataQuery( + database, + `SELECT a.attname, + pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type + FROM pg_attribute a + JOIN pg_type t ON t.typrelid = a.attrelid + WHERE t.oid = $1 AND a.attnum > 0 AND NOT a.attisdropped + ORDER BY a.attnum`, + [type.oid], + ); + const definitions = attributes.rows.map( + (attribute) => `${this.quoteIdentifier(attribute.attname)} ${attribute.data_type}`, + ); + return `CREATE TYPE ${qualified} AS (\n ${definitions.join(',\n ')}\n);`; + } + return null; + } + + async getObjectDefinition(database, schema, objectName, objectType, metadata = {}) { + if (objectType === 'table') { + return this._tableDefinition(database, schema, objectName); + } + + if (objectType === 'view' || objectType === 'materializedView') { + const result = await this._metadataQuery( + database, + `SELECT pg_get_viewdef(c.oid, true) AS definition, c.relispopulated + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relname = $2 + LIMIT 1`, + [schema, objectName], + ); + const definition = result.rows[0]?.definition; + if (!definition) return null; + const keyword = + objectType === 'materializedView' + ? 'CREATE MATERIALIZED VIEW' + : 'CREATE OR REPLACE VIEW'; + const noData = + objectType === 'materializedView' && result.rows[0]?.relispopulated === false + ? '\nWITH NO DATA' + : ''; + return `${keyword} ${this.quoteTable(database, schema, objectName)} AS\n${definition}${noData};`; + } + + if (objectType === 'procedure') { + const hasSignature = metadata.signature !== undefined && metadata.signature !== null; + const result = await this._metadataQuery( + database, + `SELECT pg_get_functiondef(p.oid) AS definition + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = $1 AND p.proname = $2 + ${hasSignature ? 'AND pg_get_function_identity_arguments(p.oid) = $3' : ''} + ORDER BY p.oid`, + hasSignature ? [schema, objectName, metadata.signature] : [schema, objectName], + ); + return result.rows.map((row) => row.definition).filter(Boolean).join('\n\n'); + } + + if (objectType === 'index') { + const result = await this._metadataQuery( + database, + `SELECT indexdef AS definition FROM pg_indexes + WHERE schemaname = $1 AND indexname = $2 LIMIT 1`, + [schema, objectName], + ); + return result.rows[0]?.definition || null; + } + + if (objectType === 'trigger') { + const result = await this._metadataQuery( + database, + `SELECT pg_get_triggerdef(t.oid, true) AS definition + FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND t.tgname = $2 AND NOT t.tgisinternal + LIMIT 1`, + [schema, objectName], + ); + return result.rows[0]?.definition || null; + } + + if (objectType === 'sequence') { + const result = await this._metadataQuery( + database, + `SELECT start_value, minimum_value, maximum_value, increment, cycle_option + FROM information_schema.sequences + WHERE sequence_schema = $1 AND sequence_name = $2 + LIMIT 1`, + [schema, objectName], + ); + const sequence = result.rows[0]; + if (!sequence) return null; + return `CREATE SEQUENCE ${this.quoteTable(database, schema, objectName)}\n START WITH ${sequence.start_value}\n INCREMENT BY ${sequence.increment}\n MINVALUE ${sequence.minimum_value}\n MAXVALUE ${sequence.maximum_value}${sequence.cycle_option === 'YES' ? '\n CYCLE' : '\n NO CYCLE'};`; + } + + if (objectType === 'type') { + return this._typeDefinition(database, schema, objectName); + } + + return null; + } +} + +module.exports = { + PostgreSqlAdapter, +}; diff --git a/src/adapters/sqlServerAdapter.js b/src/adapters/sqlServerAdapter.js new file mode 100644 index 0000000..99e517a --- /dev/null +++ b/src/adapters/sqlServerAdapter.js @@ -0,0 +1,756 @@ +'use strict'; + +const sqlServer = require('mssql'); +const { BaseAdapter } = require('./baseAdapter'); +const { normalizeDatabaseError } = require('../core/errors'); + +function sqlServerTypeName(field) { + return String( + field?.type?.declaration || field?.type?.name || field?.type || '', + ); +} + +function sqlServerColumnType(column, quoteIdentifier) { + if (column.is_user_defined) { + return [column.type_schema, column.type_name] + .filter(Boolean) + .map((part) => quoteIdentifier(part)) + .join('.'); + } + const type = String(column.type_name || '').toLowerCase(); + if (['varchar', 'char', 'varbinary', 'binary'].includes(type)) { + return `${type}(${Number(column.max_length) === -1 ? 'MAX' : column.max_length})`; + } + if (['nvarchar', 'nchar'].includes(type)) { + const length = Number(column.max_length) === -1 ? 'MAX' : Number(column.max_length) / 2; + return `${type}(${length})`; + } + if (['decimal', 'numeric'].includes(type)) { + return `${type}(${column.precision},${column.scale})`; + } + if (['datetime2', 'datetimeoffset', 'time'].includes(type)) { + return `${type}(${column.scale})`; + } + return type || 'sql_variant'; +} + +class SqlServerAdapter extends BaseAdapter { + constructor(profile, password) { + super(profile, password); + this.pool = null; + this.databasePools = new Map(); + } + + _config(database = this.profile.database || 'master') { + const instanceName = String(this.profile.instanceName || '').trim(); + const config = { + server: this.profile.host, + user: this.profile.user, + password: this.password, + database, + connectionTimeout: this.profile.connectTimeoutMs || 15000, + requestTimeout: Math.max(0, Number(this.profile.queryTimeoutMs || 0)), + pool: { + max: 5, + min: 0, + idleTimeoutMillis: 30000, + }, + options: { + encrypt: this.profile.encrypt !== false, + trustServerCertificate: Boolean(this.profile.trustServerCertificate), + enableArithAbort: true, + }, + }; + + if (instanceName) { + config.options.instanceName = instanceName; + } else { + config.port = this.profile.port || 1433; + } + return config; + } + + async connect() { + try { + this.pool = new sqlServer.ConnectionPool(this._config()); + await this.pool.connect(); + const result = await this.pool + .request() + .query("SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS version"); + this.serverVersion = result.recordset?.[0]?.version || 'SQL Server'; + this.connected = true; + return this.serverVersion; + } catch (error) { + if (this.pool) { + await this.pool.close().catch(() => {}); + this.pool = null; + } + throw normalizeDatabaseError(error, 'sqlserver'); + } + } + + async disconnect() { + await this.cancelAll(); + await this.rollbackAll(); + const extraPools = [...this.databasePools.values()]; + this.databasePools.clear(); + await Promise.allSettled( + extraPools.map(async (poolPromise) => { + const pool = await poolPromise; + await pool.close(); + }), + ); + if (this.pool) { + await this.pool.close(); + this.pool = null; + } + this.connected = false; + } + + async _poolFor(database) { + const target = database || this.profile.database || 'master'; + const primary = this.profile.database || 'master'; + if (target === primary) return this.pool; + if (this.databasePools.has(target)) { + return this.databasePools.get(target); + } + const poolPromise = (async () => { + const pool = new sqlServer.ConnectionPool(this._config(target)); + await pool.connect(); + return pool; + })(); + this.databasePools.set(target, poolPromise); + try { + return await poolPromise; + } catch (error) { + this.databasePools.delete(target); + throw error; + } + } + + async begin(sessionId, context = {}) { + if (this.transactions.has(sessionId)) { + throw new Error('Esta sesión ya tiene una transacción SQL Server activa.'); + } + try { + const pool = await this._poolFor(context.database); + const transaction = new sqlServer.Transaction(pool); + await transaction.begin(); + this.transactions.set(sessionId, transaction); + } catch (error) { + throw normalizeDatabaseError(error, 'sqlserver'); + } + } + + async commit(sessionId) { + const transaction = this.transactions.get(sessionId); + if (!transaction) { + throw new Error('No hay una transacción activa en esta sesión.'); + } + try { + await transaction.commit(); + } finally { + this.transactions.delete(sessionId); + } + } + + async rollback(sessionId) { + const transaction = this.transactions.get(sessionId); + if (!transaction) { + return; + } + try { + await transaction.rollback(); + } catch (error) { + if (error?.code !== 'EABORT') { + throw error; + } + } finally { + this.transactions.delete(sessionId); + } + } + + async execute(sessionId, sql, options) { + const transaction = this.transactions.get(sessionId); + const request = transaction + ? new sqlServer.Request(transaction) + : (await this._poolFor(options.database)).request(); + request.stream = true; + request.arrayRowMode = true; + + let currentSet = -1; + let currentBuffer = []; + let queue = Promise.resolve(); + const counts = []; + const startedSets = []; + + const queueFlush = (setIndex, rows, useBackpressure) => { + if (!rows.length || setIndex < 0) { + return; + } + if (useBackpressure) { + request.pause(); + } + queue = queue + .then(() => options.sink.rows(setIndex, rows)) + .finally(() => { + if (useBackpressure) { + request.resume(); + } + }); + }; + + request.on('recordset', (fields) => { + if (currentBuffer.length) { + queueFlush(currentSet, currentBuffer, false); + currentBuffer = []; + } + currentSet += 1; + counts[currentSet] = 0; + startedSets.push(currentSet); + const setIndex = currentSet; + const columns = (fields || []).map((field, index) => ({ + name: field.name || `Column ${index + 1}`, + type: sqlServerTypeName(field), + nullable: field.nullable, + })); + queue = queue.then(() => options.sink.start(setIndex, columns)); + }); + + request.on('row', (row) => { + if (currentSet < 0) { + return; + } + counts[currentSet] += 1; + if (options.maxRows <= 0 || counts[currentSet] <= options.maxRows) { + currentBuffer.push(row); + if (currentBuffer.length >= options.pageSize) { + const rows = currentBuffer; + currentBuffer = []; + queueFlush(currentSet, rows, true); + } + } + }); + + // El evento error necesita listener incluso usando la promesa de query(). + request.on('error', () => {}); + this.activeExecutions.set(options.executionId, { request }); + + try { + const result = await request.query(sql); + if (currentBuffer.length) { + queueFlush(currentSet, currentBuffer, false); + currentBuffer = []; + } + await queue; + + let rowCount = 0; + let truncated = false; + for (const setIndex of startedSets) { + const visibleRows = + options.maxRows > 0 + ? Math.min(counts[setIndex], options.maxRows) + : counts[setIndex]; + const setTruncated = + options.maxRows > 0 && counts[setIndex] > options.maxRows; + rowCount += visibleRows; + truncated ||= setTruncated; + await options.sink.end(setIndex, { + rowCount: visibleRows, + truncated: setTruncated, + }); + } + + return { + command: 'T-SQL', + rowCount, + rowsAffected: (result.rowsAffected || []).reduce( + (total, value) => total + Number(value || 0), + 0, + ), + resultSetCount: startedSets.length, + truncated, + }; + } catch (error) { + throw normalizeDatabaseError(error, 'sqlserver'); + } finally { + this.activeExecutions.delete(options.executionId); + } + } + + async cancel(executionId) { + const request = this.activeExecutions.get(executionId)?.request; + if (!request) { + return false; + } + try { + return request.cancel(); + } catch (_error) { + return false; + } + } + + quoteIdentifier(identifier) { + return `[${String(identifier).replaceAll(']', ']]')}]`; + } + + quoteTable(database, schema, table) { + return [database, schema, table] + .filter(Boolean) + .map((part) => this.quoteIdentifier(part)) + .join('.'); + } + + _catalog(database) { + return this.quoteIdentifier(database || this.profile.database || 'master'); + } + + async listDatabases() { + const result = await this.pool + .request() + .query("SELECT name FROM sys.databases WHERE state_desc = 'ONLINE' AND HAS_DBACCESS(name) = 1 ORDER BY name"); + return result.recordset; + } + + async listSchemas(database) { + const result = await this.pool.request().query( + `SELECT name FROM ${this._catalog(database)}.sys.schemas + WHERE name NOT IN ('sys', 'INFORMATION_SCHEMA') + ORDER BY name`, + ); + return result.recordset; + } + + async listTables(database, schema) { + const request = this.pool.request(); + request.input('schema', sqlServer.NVarChar, schema); + const result = await request.query( + `SELECT t.name + FROM ${this._catalog(database)}.sys.tables t + JOIN ${this._catalog(database)}.sys.schemas s ON s.schema_id = t.schema_id + WHERE s.name = @schema + ORDER BY t.name`, + ); + return result.recordset; + } + + async listViews(database, schema) { + const request = this.pool.request(); + request.input('schema', sqlServer.NVarChar, schema); + const result = await request.query( + `SELECT v.name + FROM ${this._catalog(database)}.sys.views v + JOIN ${this._catalog(database)}.sys.schemas s ON s.schema_id = v.schema_id + WHERE s.name = @schema + ORDER BY v.name`, + ); + return result.recordset; + } + + async listColumns(database, schema, objectName) { + const request = this.pool.request(); + request.input('schema', sqlServer.NVarChar, schema); + request.input('object', sqlServer.NVarChar, objectName); + const result = await request.query( + `SELECT c.name, + ty.name AS type, + c.is_nullable AS nullable, + c.column_id AS position + FROM ${this._catalog(database)}.sys.columns c + JOIN ${this._catalog(database)}.sys.objects o ON o.object_id = c.object_id + JOIN ${this._catalog(database)}.sys.schemas s ON s.schema_id = o.schema_id + JOIN ${this._catalog(database)}.sys.types ty ON ty.user_type_id = c.user_type_id + WHERE s.name = @schema AND o.name = @object + ORDER BY c.column_id`, + ); + return result.recordset; + } + + async listProcedures(database, schema) { + const request = this.pool.request(); + request.input('schema', sqlServer.NVarChar, schema); + const result = await request.query( + `SELECT o.name, + CASE WHEN o.type IN ('P', 'PC') THEN 'PROCEDURE' ELSE 'FUNCTION' END AS type + FROM ${this._catalog(database)}.sys.objects o + JOIN ${this._catalog(database)}.sys.schemas s ON s.schema_id = o.schema_id + WHERE s.name = @schema AND o.type IN ('P', 'PC', 'FN', 'IF', 'TF') + ORDER BY o.name`, + ); + return result.recordset; + } + + async listIndexes(database, schema) { + const request = this.pool.request(); + request.input('schema', sqlServer.NVarChar, schema); + const result = await request.query( + `SELECT i.name, o.name AS tableName, + i.is_primary_key AS isPrimaryKey, + i.is_unique_constraint AS isUniqueConstraint + FROM ${this._catalog(database)}.sys.indexes i + JOIN ${this._catalog(database)}.sys.objects o ON o.object_id = i.object_id + JOIN ${this._catalog(database)}.sys.schemas s ON s.schema_id = o.schema_id + WHERE s.name = @schema AND i.name IS NOT NULL AND i.is_hypothetical = 0 + ORDER BY o.name, i.name`, + ); + return result.recordset; + } + + async listTriggers(database, schema) { + const request = this.pool.request(); + request.input('schema', sqlServer.NVarChar, schema); + const result = await request.query( + `SELECT tr.name, o.name AS tableName + FROM ${this._catalog(database)}.sys.triggers tr + JOIN ${this._catalog(database)}.sys.objects o ON o.object_id = tr.parent_id + JOIN ${this._catalog(database)}.sys.schemas s ON s.schema_id = o.schema_id + WHERE s.name = @schema AND tr.parent_class = 1 + ORDER BY tr.name`, + ); + return result.recordset; + } + + async listSequences(database, schema) { + const request = this.pool.request(); + request.input('schema', sqlServer.NVarChar, schema); + const result = await request.query( + `SELECT seq.name + FROM ${this._catalog(database)}.sys.sequences seq + JOIN ${this._catalog(database)}.sys.schemas s ON s.schema_id = seq.schema_id + WHERE s.name = @schema + ORDER BY seq.name`, + ); + return result.recordset; + } + + async listTypes(database, schema) { + const request = this.pool.request(); + request.input('schema', sqlServer.NVarChar, schema); + const result = await request.query( + `SELECT ty.name, + CASE WHEN ty.is_table_type = 1 THEN 'TABLE TYPE' ELSE 'TYPE' END AS type + FROM ${this._catalog(database)}.sys.types ty + JOIN ${this._catalog(database)}.sys.schemas s ON s.schema_id = ty.schema_id + WHERE s.name = @schema AND ty.is_user_defined = 1 + ORDER BY ty.name`, + ); + return result.recordset; + } + + async listSynonyms(database, schema) { + const request = this.pool.request(); + request.input('schema', sqlServer.NVarChar, schema); + const result = await request.query( + `SELECT sy.name, sy.base_object_name AS target + FROM ${this._catalog(database)}.sys.synonyms sy + JOIN ${this._catalog(database)}.sys.schemas s ON s.schema_id = sy.schema_id + WHERE s.name = @schema + ORDER BY sy.name`, + ); + return result.recordset; + } + + async _moduleDefinition(database, schema, objectName, trigger = false) { + const request = this.pool.request(); + request.input('schema', sqlServer.NVarChar, schema); + request.input('object', sqlServer.NVarChar, objectName); + const query = trigger + ? `SELECT m.definition + FROM ${this._catalog(database)}.sys.triggers tr + JOIN ${this._catalog(database)}.sys.objects parent ON parent.object_id = tr.parent_id + JOIN ${this._catalog(database)}.sys.schemas s ON s.schema_id = parent.schema_id + JOIN ${this._catalog(database)}.sys.sql_modules m ON m.object_id = tr.object_id + WHERE s.name = @schema AND tr.name = @object` + : `SELECT m.definition + FROM ${this._catalog(database)}.sys.objects o + JOIN ${this._catalog(database)}.sys.schemas s ON s.schema_id = o.schema_id + JOIN ${this._catalog(database)}.sys.sql_modules m ON m.object_id = o.object_id + WHERE s.name = @schema AND o.name = @object`; + const result = await request.query(query); + return result.recordset?.[0]?.definition || null; + } + + async _tableDefinition(database, schema, objectName) { + const catalog = this._catalog(database); + const request = this.pool.request(); + request.input('schema', sqlServer.NVarChar, schema); + request.input('object', sqlServer.NVarChar, objectName); + const columnsResult = await request.query( + `SELECT c.name, + ty.name AS type_name, + tys.name AS type_schema, + ty.is_user_defined, + c.max_length, + c.precision, + c.scale, + c.is_nullable, + c.collation_name, + c.is_identity, + ic.seed_value, + ic.increment_value, + cc.definition AS computed_definition, + cc.is_persisted, + dc.name AS default_name, + dc.definition AS default_definition + FROM ${catalog}.sys.columns c + JOIN ${catalog}.sys.objects o ON o.object_id = c.object_id + JOIN ${catalog}.sys.schemas s ON s.schema_id = o.schema_id + JOIN ${catalog}.sys.types ty ON ty.user_type_id = c.user_type_id + LEFT JOIN ${catalog}.sys.schemas tys ON tys.schema_id = ty.schema_id + LEFT JOIN ${catalog}.sys.identity_columns ic + ON ic.object_id = c.object_id AND ic.column_id = c.column_id + LEFT JOIN ${catalog}.sys.computed_columns cc + ON cc.object_id = c.object_id AND cc.column_id = c.column_id + LEFT JOIN ${catalog}.sys.default_constraints dc + ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column_id + WHERE s.name = @schema AND o.name = @object AND o.type = 'U' + ORDER BY c.column_id`, + ); + if (!columnsResult.recordset?.length) return null; + + const lines = columnsResult.recordset.map((column) => { + const name = this.quoteIdentifier(column.name); + if (column.computed_definition) { + return `${name} AS ${column.computed_definition}${column.is_persisted ? ' PERSISTED' : ''}`; + } + let value = `${name} ${sqlServerColumnType(column, (part) => this.quoteIdentifier(part))}`; + if (column.collation_name) value += ` COLLATE ${column.collation_name}`; + if (column.is_identity) { + value += ` IDENTITY(${column.seed_value ?? 1},${column.increment_value ?? 1})`; + } + if (column.default_definition) { + value += ` CONSTRAINT ${this.quoteIdentifier(column.default_name)} DEFAULT ${column.default_definition}`; + } + value += column.is_nullable ? ' NULL' : ' NOT NULL'; + return value; + }); + + const keysRequest = this.pool.request(); + keysRequest.input('schema', sqlServer.NVarChar, schema); + keysRequest.input('object', sqlServer.NVarChar, objectName); + const keys = await keysRequest.query( + `SELECT kc.name, kc.type, c.name AS column_name, ic.key_ordinal, ic.is_descending_key + FROM ${catalog}.sys.key_constraints kc + JOIN ${catalog}.sys.tables t ON t.object_id = kc.parent_object_id + JOIN ${catalog}.sys.schemas s ON s.schema_id = t.schema_id + JOIN ${catalog}.sys.index_columns ic + ON ic.object_id = kc.parent_object_id AND ic.index_id = kc.unique_index_id + JOIN ${catalog}.sys.columns c + ON c.object_id = ic.object_id AND c.column_id = ic.column_id + WHERE s.name = @schema AND t.name = @object AND ic.key_ordinal > 0 + ORDER BY kc.name, ic.key_ordinal`, + ); + const keyGroups = new Map(); + for (const key of keys.recordset || []) { + if (!keyGroups.has(key.name)) keyGroups.set(key.name, []); + keyGroups.get(key.name).push(key); + } + for (const [name, keyColumns] of keyGroups) { + const keyword = keyColumns[0].type === 'PK' ? 'PRIMARY KEY' : 'UNIQUE'; + const columns = keyColumns + .map((column) => `${this.quoteIdentifier(column.column_name)}${column.is_descending_key ? ' DESC' : ''}`) + .join(', '); + lines.push(`CONSTRAINT ${this.quoteIdentifier(name)} ${keyword} (${columns})`); + } + + const checksRequest = this.pool.request(); + checksRequest.input('schema', sqlServer.NVarChar, schema); + checksRequest.input('object', sqlServer.NVarChar, objectName); + const checks = await checksRequest.query( + `SELECT cc.name, cc.definition + FROM ${catalog}.sys.check_constraints cc + JOIN ${catalog}.sys.tables t ON t.object_id = cc.parent_object_id + JOIN ${catalog}.sys.schemas s ON s.schema_id = t.schema_id + WHERE s.name = @schema AND t.name = @object + ORDER BY cc.name`, + ); + for (const check of checks.recordset || []) { + lines.push(`CONSTRAINT ${this.quoteIdentifier(check.name)} CHECK ${check.definition}`); + } + + const foreignRequest = this.pool.request(); + foreignRequest.input('schema', sqlServer.NVarChar, schema); + foreignRequest.input('object', sqlServer.NVarChar, objectName); + const foreignKeys = await foreignRequest.query( + `SELECT fk.name, + pc.name AS parent_column, + rs.name AS referenced_schema, + rt.name AS referenced_table, + rc.name AS referenced_column, + fkc.constraint_column_id, + fk.delete_referential_action_desc AS delete_action, + fk.update_referential_action_desc AS update_action + FROM ${catalog}.sys.foreign_keys fk + JOIN ${catalog}.sys.foreign_key_columns fkc + ON fkc.constraint_object_id = fk.object_id + JOIN ${catalog}.sys.tables pt ON pt.object_id = fk.parent_object_id + JOIN ${catalog}.sys.schemas ps ON ps.schema_id = pt.schema_id + JOIN ${catalog}.sys.columns pc + ON pc.object_id = pt.object_id AND pc.column_id = fkc.parent_column_id + JOIN ${catalog}.sys.tables rt ON rt.object_id = fk.referenced_object_id + JOIN ${catalog}.sys.schemas rs ON rs.schema_id = rt.schema_id + JOIN ${catalog}.sys.columns rc + ON rc.object_id = rt.object_id AND rc.column_id = fkc.referenced_column_id + WHERE ps.name = @schema AND pt.name = @object + ORDER BY fk.name, fkc.constraint_column_id`, + ); + const foreignGroups = new Map(); + for (const foreignKey of foreignKeys.recordset || []) { + if (!foreignGroups.has(foreignKey.name)) foreignGroups.set(foreignKey.name, []); + foreignGroups.get(foreignKey.name).push(foreignKey); + } + for (const [name, keyColumns] of foreignGroups) { + const first = keyColumns[0]; + const source = keyColumns.map((row) => this.quoteIdentifier(row.parent_column)).join(', '); + const target = keyColumns.map((row) => this.quoteIdentifier(row.referenced_column)).join(', '); + let definition = `CONSTRAINT ${this.quoteIdentifier(name)} FOREIGN KEY (${source}) REFERENCES ${this.quoteTable(null, first.referenced_schema, first.referenced_table)} (${target})`; + if (first.delete_action && first.delete_action !== 'NO_ACTION') { + definition += ` ON DELETE ${String(first.delete_action).replaceAll('_', ' ')}`; + } + if (first.update_action && first.update_action !== 'NO_ACTION') { + definition += ` ON UPDATE ${String(first.update_action).replaceAll('_', ' ')}`; + } + lines.push(definition); + } + + return `CREATE TABLE ${this.quoteTable(null, schema, objectName)} (\n ${lines.join(',\n ')}\n);`; + } + + async _indexDefinition(database, schema, objectName) { + const request = this.pool.request(); + request.input('schema', sqlServer.NVarChar, schema); + request.input('object', sqlServer.NVarChar, objectName); + const result = await request.query( + `SELECT i.name, i.is_unique, i.is_primary_key, i.is_unique_constraint, + i.type_desc, i.filter_definition, + t.name AS table_name, c.name AS column_name, + ic.key_ordinal, ic.is_descending_key, ic.is_included_column, ic.index_column_id + FROM ${this._catalog(database)}.sys.indexes i + JOIN ${this._catalog(database)}.sys.tables t ON t.object_id = i.object_id + JOIN ${this._catalog(database)}.sys.schemas s ON s.schema_id = t.schema_id + JOIN ${this._catalog(database)}.sys.index_columns ic + ON ic.object_id = i.object_id AND ic.index_id = i.index_id + JOIN ${this._catalog(database)}.sys.columns c + ON c.object_id = ic.object_id AND c.column_id = ic.column_id + WHERE s.name = @schema AND i.name = @object + ORDER BY ic.is_included_column, ic.key_ordinal, ic.index_column_id`, + ); + const rows = result.recordset || []; + if (!rows.length) return null; + const first = rows[0]; + const keys = rows + .filter((row) => !row.is_included_column) + .map((row) => `${this.quoteIdentifier(row.column_name)}${row.is_descending_key ? ' DESC' : ''}`) + .join(', '); + const includes = rows + .filter((row) => row.is_included_column) + .map((row) => this.quoteIdentifier(row.column_name)); + const table = this.quoteTable(null, schema, first.table_name); + if (first.is_primary_key || first.is_unique_constraint) { + const keyword = first.is_primary_key ? 'PRIMARY KEY' : 'UNIQUE'; + return `ALTER TABLE ${table} ADD CONSTRAINT ${this.quoteIdentifier(first.name)} ${keyword} (${keys});`; + } + const indexKind = String(first.type_desc || '') + .replaceAll('_', ' ') + .replace(/\s*INDEX$/i, '') + .trim(); + let definition = `CREATE${first.is_unique ? ' UNIQUE' : ''}${indexKind ? ` ${indexKind}` : ''} INDEX ${this.quoteIdentifier(first.name)} ON ${table} (${keys})`; + if (includes.length) definition += ` INCLUDE (${includes.join(', ')})`; + if (first.filter_definition) definition += ` WHERE ${first.filter_definition}`; + return `${definition};`; + } + + async _typeDefinition(database, schema, objectName) { + const request = this.pool.request(); + request.input('schema', sqlServer.NVarChar, schema); + request.input('object', sqlServer.NVarChar, objectName); + const result = await request.query( + `SELECT ty.name, ty.is_table_type, ty.is_nullable, + base.name AS type_name, ty.max_length, ty.precision, ty.scale + FROM ${this._catalog(database)}.sys.types ty + JOIN ${this._catalog(database)}.sys.schemas s ON s.schema_id = ty.schema_id + LEFT JOIN ${this._catalog(database)}.sys.types base + ON base.user_type_id = ty.system_type_id + AND base.user_type_id = base.system_type_id + WHERE s.name = @schema AND ty.name = @object AND ty.is_user_defined = 1`, + ); + const type = result.recordset?.[0]; + if (!type) return null; + const qualified = this.quoteTable(null, schema, objectName); + if (!type.is_table_type) { + const base = sqlServerColumnType(type, (part) => this.quoteIdentifier(part)); + return `CREATE TYPE ${qualified} FROM ${base}${type.is_nullable ? ' NULL' : ' NOT NULL'};`; + } + + const columnsRequest = this.pool.request(); + columnsRequest.input('schema', sqlServer.NVarChar, schema); + columnsRequest.input('object', sqlServer.NVarChar, objectName); + const columns = await columnsRequest.query( + `SELECT c.name, base.name AS type_name, bs.name AS type_schema, + base.is_user_defined, c.max_length, c.precision, c.scale, c.is_nullable + FROM ${this._catalog(database)}.sys.table_types tt + JOIN ${this._catalog(database)}.sys.schemas s ON s.schema_id = tt.schema_id + JOIN ${this._catalog(database)}.sys.columns c ON c.object_id = tt.type_table_object_id + JOIN ${this._catalog(database)}.sys.types base ON base.user_type_id = c.user_type_id + LEFT JOIN ${this._catalog(database)}.sys.schemas bs ON bs.schema_id = base.schema_id + WHERE s.name = @schema AND tt.name = @object + ORDER BY c.column_id`, + ); + const lines = (columns.recordset || []).map( + (column) => `${this.quoteIdentifier(column.name)} ${sqlServerColumnType(column, (part) => this.quoteIdentifier(part))}${column.is_nullable ? ' NULL' : ' NOT NULL'}`, + ); + return `CREATE TYPE ${qualified} AS TABLE (\n ${lines.join(',\n ')}\n);`; + } + + async getObjectDefinition(database, schema, objectName, objectType) { + if (objectType === 'table') { + return this._tableDefinition(database, schema, objectName); + } + if (['view', 'procedure'].includes(objectType)) { + return this._moduleDefinition(database, schema, objectName, false); + } + if (objectType === 'trigger') { + return this._moduleDefinition(database, schema, objectName, true); + } + if (objectType === 'index') { + return this._indexDefinition(database, schema, objectName); + } + if (objectType === 'type') { + return this._typeDefinition(database, schema, objectName); + } + if (objectType === 'sequence') { + const request = this.pool.request(); + request.input('schema', sqlServer.NVarChar, schema); + request.input('object', sqlServer.NVarChar, objectName); + const result = await request.query( + `SELECT seq.start_value, seq.increment, seq.minimum_value, seq.maximum_value, seq.is_cycling + FROM ${this._catalog(database)}.sys.sequences seq + JOIN ${this._catalog(database)}.sys.schemas s ON s.schema_id = seq.schema_id + WHERE s.name = @schema AND seq.name = @object`, + ); + const sequence = result.recordset?.[0]; + if (!sequence) return null; + return `CREATE SEQUENCE ${this.quoteTable(null, schema, objectName)}\n START WITH ${sequence.start_value}\n INCREMENT BY ${sequence.increment}\n MINVALUE ${sequence.minimum_value}\n MAXVALUE ${sequence.maximum_value}${sequence.is_cycling ? '\n CYCLE' : '\n NO CYCLE'};`; + } + if (objectType === 'synonym') { + const request = this.pool.request(); + request.input('schema', sqlServer.NVarChar, schema); + request.input('object', sqlServer.NVarChar, objectName); + const result = await request.query( + `SELECT sy.base_object_name + FROM ${this._catalog(database)}.sys.synonyms sy + JOIN ${this._catalog(database)}.sys.schemas s ON s.schema_id = sy.schema_id + WHERE s.name = @schema AND sy.name = @object`, + ); + const target = result.recordset?.[0]?.base_object_name; + return target + ? `CREATE SYNONYM ${this.quoteTable(null, schema, objectName)} FOR ${target};` + : null; + } + return null; + } +} + +module.exports = { + SqlServerAdapter, +}; diff --git a/src/adapters/sqliteAdapter.js b/src/adapters/sqliteAdapter.js new file mode 100644 index 0000000..537ee87 --- /dev/null +++ b/src/adapters/sqliteAdapter.js @@ -0,0 +1,341 @@ +'use strict'; + +const path = require('node:path'); +const { randomUUID } = require('node:crypto'); +const { Worker } = require('node:worker_threads'); +const { BaseAdapter } = require('./baseAdapter'); +const { QueryCancelledError, normalizeDatabaseError } = require('../core/errors'); + +class SqliteAdapter extends BaseAdapter { + constructor(profile, password) { + super(profile, password); + this.worker = null; + this.pending = new Map(); + this.cancelledRequests = new Set(); + this.restarting = false; + } + + _spawnWorker() { + const worker = new Worker(path.join(__dirname, 'sqliteWorker.js')); + this.worker = worker; + + worker.on('message', (message) => this._onWorkerMessage(worker, message)); + worker.on('error', (error) => this._rejectWorkerPending(worker, error)); + worker.on('exit', (code) => { + if (this.worker === worker && !this.restarting) { + this.worker = null; + this.connected = false; + } + if (code !== 0) { + this._rejectWorkerPending(worker, new Error(`SQLite worker finalizó con código ${code}.`)); + } + }); + return worker; + } + + _rejectWorkerPending(worker, error) { + for (const [requestId, pending] of [...this.pending.entries()]) { + if (pending.worker !== worker) { + continue; + } + this.pending.delete(requestId); + if (this.cancelledRequests.has(requestId)) { + this.cancelledRequests.delete(requestId); + pending.reject(new QueryCancelledError()); + } else { + pending.reject(error); + } + } + } + + async _onWorkerMessage(worker, message) { + const pending = this.pending.get(message.requestId); + if (!pending || pending.worker !== worker) { + return; + } + + if (message.type === 'stream') { + try { + if (message.event === 'start') { + await pending.sink.start(message.setIndex, message.columns); + } else if (message.event === 'rows') { + await pending.sink.rows(message.setIndex, message.rows); + } else if (message.event === 'end') { + await pending.sink.end(message.setIndex, { + rowCount: message.rowCount, + truncated: message.truncated, + }); + } + worker.postMessage({ + type: 'ack', + requestId: message.requestId, + sequence: message.sequence, + }); + } catch (error) { + pending.streamError = error; + worker.postMessage({ + type: 'ack', + requestId: message.requestId, + sequence: message.sequence, + }); + } + return; + } + + this.pending.delete(message.requestId); + if (message.type === 'error') { + const error = new Error(message.error?.message || 'Error SQLite'); + error.code = message.error?.code; + pending.reject(normalizeDatabaseError(error, 'sqlite')); + return; + } + + if (pending.streamError) { + pending.reject(pending.streamError); + return; + } + pending.resolve(message.result); + } + + _request(type, payload = {}, options = {}) { + if (!this.worker) { + return Promise.reject(new Error('SQLite no está conectado.')); + } + const requestId = options.requestId || randomUUID(); + const worker = this.worker; + return new Promise((resolve, reject) => { + this.pending.set(requestId, { + resolve, + reject, + sink: options.sink, + streamError: null, + worker, + }); + worker.postMessage({ type, requestId, payload }); + }); + } + + async _openCurrentWorker() { + return this._request('open', { + filePath: this.profile.filePath, + readOnly: this.profile.readOnly, + }); + } + + async connect() { + try { + this._spawnWorker(); + const result = await this._openCurrentWorker(); + this.serverVersion = result.version || 'SQLite'; + this.connected = true; + return this.serverVersion; + } catch (error) { + if (this.worker) { + await this.worker.terminate().catch(() => {}); + this.worker = null; + } + throw normalizeDatabaseError(error, 'sqlite'); + } + } + + async disconnect() { + if (this.worker) { + const worker = this.worker; + if (this.activeExecutions.size === 0) { + await this._request('close').catch(() => {}); + this.worker = null; + await worker.terminate().catch(() => {}); + } else { + for (const [requestId, pending] of this.pending) { + if (pending.worker === worker) this.cancelledRequests.add(requestId); + } + this.worker = null; + await worker.terminate().catch(() => {}); + this._rejectWorkerPending(worker, new QueryCancelledError()); + } + } + this.transactions.clear(); + this.activeExecutions.clear(); + this.connected = false; + } + + async begin(sessionId) { + if (this.transactions.size > 0 && !this.transactions.has(sessionId)) { + throw new Error( + 'SQLite admite una única transacción activa por archivo en Simple DB.', + ); + } + if (this.transactions.has(sessionId)) { + throw new Error('Esta sesión ya tiene una transacción SQLite activa.'); + } + await this._request('begin'); + this.transactions.set(sessionId, true); + } + + async commit(sessionId) { + if (!this.transactions.has(sessionId)) { + throw new Error('No hay una transacción activa en esta sesión.'); + } + await this._request('commit'); + this.transactions.delete(sessionId); + } + + async rollback(sessionId) { + if (!this.transactions.has(sessionId)) { + return; + } + await this._request('rollback'); + this.transactions.delete(sessionId); + } + + async execute(sessionId, sql, options) { + if (this.transactions.size > 0 && !this.transactions.has(sessionId)) { + throw new Error( + 'SQLite tiene una transacción activa en otro editor. Finaliza esa transacción antes de ejecutar aquí.', + ); + } + const requestId = randomUUID(); + this.activeExecutions.set(options.executionId, { requestId }); + try { + const result = await this._request( + 'execute', + { + sql, + maxRows: options.maxRows, + pageSize: options.pageSize, + }, + { requestId, sink: options.sink }, + ); + if (result.inTransaction) { + this.transactions.set(sessionId, true); + } else { + this.transactions.delete(sessionId); + } + return result; + } catch (error) { + if (error instanceof QueryCancelledError) { + throw error; + } + throw normalizeDatabaseError(error, 'sqlite'); + } finally { + this.activeExecutions.delete(options.executionId); + } + } + + async cancel(executionId) { + const active = this.activeExecutions.get(executionId); + if (!active || !this.worker) { + return false; + } + + const oldWorker = this.worker; + this.cancelledRequests.add(active.requestId); + this.restarting = true; + try { + await oldWorker.terminate(); + this._rejectWorkerPending(oldWorker, new QueryCancelledError()); + this.worker = null; + this.transactions.clear(); + this._spawnWorker(); + await this._openCurrentWorker(); + this.connected = true; + return true; + } finally { + this.restarting = false; + } + } + + async _queryAll(sql) { + const result = await this._request('queryAll', { sql }); + return result.rows || []; + } + + async listDatabases() { + const databases = await this._queryAll('PRAGMA database_list'); + return databases.map((database) => ({ + name: database.name || 'main', + file: database.file || this.profile.filePath, + })); + } + + async listSchemas(database) { + return [{ name: database || 'main' }]; + } + + async listTables(_database, schema) { + return this._queryAll( + `SELECT name FROM ${this.quoteIdentifier(schema || 'main')}.sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`, + ); + } + + async listViews(_database, schema) { + return this._queryAll( + `SELECT name FROM ${this.quoteIdentifier(schema || 'main')}.sqlite_master + WHERE type = 'view' ORDER BY name`, + ); + } + + async listColumns(_database, schema, objectName) { + const rows = await this._queryAll( + `PRAGMA ${this.quoteIdentifier(schema || 'main')}.table_info(${this.quoteIdentifier(objectName)})`, + ); + return rows.map((column) => ({ + name: column.name, + type: column.type || '', + nullable: !column.notnull, + position: Number(column.cid || 0) + 1, + })); + } + + async listProcedures() { + return []; + } + + async listIndexes(_database, schema) { + return this._queryAll( + `SELECT name, tbl_name AS tableName + FROM ${this.quoteIdentifier(schema || 'main')}.sqlite_master + WHERE type = 'index' AND sql IS NOT NULL + ORDER BY tbl_name, name`, + ); + } + + async listTriggers(_database, schema) { + return this._queryAll( + `SELECT name, tbl_name AS tableName + FROM ${this.quoteIdentifier(schema || 'main')}.sqlite_master + WHERE type = 'trigger' + ORDER BY name`, + ); + } + + _stringLiteral(value) { + return `'${String(value).replaceAll("'", "''")}'`; + } + + async getObjectDefinition(_database, schema, objectName, objectType) { + const sqliteType = { + table: 'table', + view: 'view', + index: 'index', + trigger: 'trigger', + }[objectType]; + if (!sqliteType) { + return null; + } + const rows = await this._queryAll( + `SELECT sql AS definition + FROM ${this.quoteIdentifier(schema || 'main')}.sqlite_master + WHERE type = ${this._stringLiteral(sqliteType)} + AND name = ${this._stringLiteral(objectName)} + LIMIT 1`, + ); + const definition = rows[0]?.definition; + return definition ? `${String(definition).trim().replace(/;$/, '')};` : null; + } +} + +module.exports = { + SqliteAdapter, +}; diff --git a/src/adapters/sqliteWorker.js b/src/adapters/sqliteWorker.js new file mode 100644 index 0000000..1edc801 --- /dev/null +++ b/src/adapters/sqliteWorker.js @@ -0,0 +1,456 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { parentPort } = require('node:worker_threads'); +const initSqlJs = require('sql.js'); +const { createCodeMask } = require('../sql/sqlSplitter'); + +let SQL = null; +let database = null; +let filePath = ''; +let readOnly = false; +let diskFingerprint = null; +let inTransaction = false; +let transactionDirty = false; +let savepointDepth = 0; +let savepointStartedTransaction = false; +let commandQueue = Promise.resolve(); +let nextSequence = 1; +const acknowledgements = new Map(); +const MIN_SAFE_BIGINT = BigInt(Number.MIN_SAFE_INTEGER); +const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); + +function normalizeSqliteInteger(value) { + if ( + typeof value === 'bigint' && + value >= MIN_SAFE_BIGINT && + value <= MAX_SAFE_BIGINT + ) { + return Number(value); + } + return value; +} + +function serializeError(error) { + return { + message: error?.message || String(error), + code: error?.code || 'SQLITE_ERROR', + stack: error?.stack || '', + }; +} + +function databaseChangedBy(sql) { + const code = createCodeMask(sql).replace(/\s+/g, ' ').trim(); + return ( + /\b(?:INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|REPLACE|VACUUM|REINDEX|ANALYZE)\b/i.test( + code, + ) || /^PRAGMA\s+[^;=]+=/i.test(code) + ); +} + +function changesRows(sql) { + const code = createCodeMask(sql).replace(/\s+/g, ' ').trim(); + return /\b(?:INSERT|UPDATE|DELETE|REPLACE)\b/i.test(code); +} + +function transactionControl(sql) { + const code = createCodeMask(sql).replace(/\s+/g, ' ').trim(); + if (/^BEGIN(?:\s+(?:DEFERRED|IMMEDIATE|EXCLUSIVE))?(?:\s+TRANSACTION)?\b/i.test(code)) { + return 'begin'; + } + if (/^SAVEPOINT\b/i.test(code)) return 'savepoint'; + if (/^RELEASE(?:\s+SAVEPOINT)?\b/i.test(code)) return 'release'; + if (/^(?:COMMIT|END(?:\s+TRANSACTION)?)\b/i.test(code)) return 'commit'; + if (/^ROLLBACK\s+TO\b/i.test(code)) return 'rollbackTo'; + if (/^ROLLBACK\b/i.test(code)) return 'rollback'; + return null; +} + +function applyTransactionControl(control, wasInTransaction) { + if (control === 'begin') { + inTransaction = true; + transactionDirty = false; + savepointDepth = 0; + savepointStartedTransaction = false; + return false; + } + if (control === 'savepoint') { + if (!wasInTransaction) { + inTransaction = true; + savepointStartedTransaction = true; + } + savepointDepth += 1; + return false; + } + if (control === 'release') { + savepointDepth = Math.max(0, savepointDepth - 1); + if (savepointDepth === 0 && savepointStartedTransaction) { + inTransaction = false; + savepointStartedTransaction = false; + const shouldPersist = transactionDirty; + transactionDirty = false; + return shouldPersist; + } + return false; + } + if (control === 'commit') { + inTransaction = false; + savepointDepth = 0; + savepointStartedTransaction = false; + const shouldPersist = transactionDirty; + transactionDirty = false; + return shouldPersist; + } + if (control === 'rollback') { + inTransaction = false; + transactionDirty = false; + savepointDepth = 0; + savepointStartedTransaction = false; + } + return false; +} + +function assertWritableSql(sql) { + if (readOnly && databaseChangedBy(sql)) { + const error = new Error('La conexión SQLite está configurada como solo lectura.'); + error.code = 'SQLITE_READONLY'; + throw error; + } +} + +function fileFingerprint(targetPath) { + try { + const stat = fs.statSync(targetPath, { bigint: true }); + return `${stat.size}:${stat.mtimeNs}`; + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } +} + +function readDiskFingerprint() { + return [ + fileFingerprint(filePath), + fileFingerprint(`${filePath}-wal`), + fileFingerprint(`${filePath}-journal`), + ].join('|'); +} + +function assertNoActiveWal() { + try { + if (fs.statSync(`${filePath}-wal`).size > 0) { + const error = new Error( + 'La base SQLite tiene un archivo WAL activo. Para evitar leer una instantánea incompleta, haz CHECKPOINT/cierra el proceso escritor y vuelve a conectar.', + ); + error.code = 'SQLITE_BUSY_WAL'; + throw error; + } + } catch (error) { + if (error?.code === 'ENOENT') return; + throw error; + } +} + +function assertDiskUnchanged() { + const current = readDiskFingerprint(); + if (current !== diskFingerprint) { + const error = new Error( + 'El archivo SQLite fue modificado por otra aplicación desde que se abrió. Simple DB no lo sobrescribirá; desconecta y vuelve a conectar para recargarlo.', + ); + error.code = 'SQLITE_BUSY_EXTERNAL'; + throw error; + } +} + +function persistDatabase() { + if (readOnly || !database || !filePath) { + return; + } + assertDiskUnchanged(); + const bytes = Buffer.from(database.export()); + database.run('PRAGMA foreign_keys = ON'); + const temporaryPath = `${filePath}.simple-db.tmp`; + fs.writeFileSync(temporaryPath, bytes); + try { + fs.renameSync(temporaryPath, filePath); + } catch (_error) { + fs.writeFileSync(filePath, bytes); + try { + fs.unlinkSync(temporaryPath); + } catch (_unlinkError) { + // El archivo temporal se limpiará en la próxima escritura si sigue presente. + } + } + diskFingerprint = readDiskFingerprint(); +} + +async function ensureSqlJs() { + if (SQL) { + return SQL; + } + SQL = await initSqlJs({ + locateFile: (filename) => require.resolve(`sql.js/dist/${filename}`), + }); + return SQL; +} + +async function openDatabase(payload) { + await ensureSqlJs(); + filePath = path.resolve(String(payload.filePath)); + readOnly = Boolean(payload.readOnly); + + if (readOnly && !fs.existsSync(filePath)) { + const error = new Error(`No existe el archivo SQLite: ${filePath}`); + error.code = 'SQLITE_CANTOPEN'; + throw error; + } + + const exists = fs.existsSync(filePath); + if (exists) assertNoActiveWal(); + diskFingerprint = readDiskFingerprint(); + const data = exists ? fs.readFileSync(filePath) : null; + database = data?.length + ? new SQL.Database(new Uint8Array(data)) + : new SQL.Database(); + database.run('PRAGMA foreign_keys = ON'); + inTransaction = false; + transactionDirty = false; + savepointDepth = 0; + savepointStartedTransaction = false; + + if (!exists && !readOnly) { + persistDatabase(); + } + + const version = database.exec('SELECT sqlite_version() AS version'); + return { + version: version[0]?.values?.[0]?.[0] || 'SQLite', + }; +} + +function queryAll(sql) { + if (!database) { + throw new Error('SQLite no está conectado.'); + } + assertDiskUnchanged(); + const results = database.exec(sql, { useBigInt: true }); + if (!results.length) { + return []; + } + const first = results[0]; + return first.values.map((values) => + Object.fromEntries( + first.columns.map((column, index) => [ + column, + normalizeSqliteInteger(values[index]), + ]), + ), + ); +} + +function sendStreamEvent(requestId, event, payload) { + const sequence = nextSequence; + nextSequence += 1; + parentPort.postMessage({ + type: 'stream', + requestId, + sequence, + event, + ...payload, + }); + return new Promise((resolve) => { + acknowledgements.set(`${requestId}:${sequence}`, resolve); + }); +} + +async function executeSql(requestId, payload) { + if (!database) { + throw new Error('SQLite no está conectado.'); + } + // sql.js trabaja sobre un snapshot en memoria. Si otro proceso modifica el + // archivo (o crea WAL/journal), se obliga a reconectar también para lecturas. + assertDiskUnchanged(); + assertWritableSql(payload.sql); + + const pageSize = Math.max(1, Number(payload.pageSize || 500)); + const maxRows = Math.max(0, Number(payload.maxRows || 0)); + const hasLimit = maxRows > 0; + let resultSetIndex = 0; + let rowCount = 0; + let rowsAffected = 0; + let truncated = false; + let statementCount = 0; + let shouldPersist = false; + + for (const statement of database.iterateStatements(payload.sql)) { + statementCount += 1; + const statementSql = statement.getSQL(); + const control = transactionControl(statementSql); + const wasInTransaction = inTransaction; + const columns = statement.getColumnNames().map((name) => ({ name, type: '' })); + let setRows = 0; + let setTruncated = false; + let buffer = []; + + if (columns.length > 0) { + await sendStreamEvent(requestId, 'start', { + setIndex: resultSetIndex, + columns, + }); + } + + try { + while (statement.step()) { + if (columns.length === 0) { + continue; + } + setRows += 1; + if (!hasLimit || setRows <= maxRows) { + buffer.push( + statement + .get(null, { useBigInt: true }) + .map((value) => normalizeSqliteInteger(value)), + ); + if (buffer.length >= pageSize) { + await sendStreamEvent(requestId, 'rows', { + setIndex: resultSetIndex, + rows: buffer, + }); + buffer = []; + } + } else { + setTruncated = true; + } + } + + if (buffer.length > 0) { + await sendStreamEvent(requestId, 'rows', { + setIndex: resultSetIndex, + rows: buffer, + }); + } + + if (changesRows(statementSql)) { + rowsAffected += Number(database.getRowsModified() || 0); + } + if (columns.length > 0) { + const visibleRows = hasLimit ? Math.min(setRows, maxRows) : setRows; + await sendStreamEvent(requestId, 'end', { + setIndex: resultSetIndex, + rowCount: visibleRows, + truncated: setTruncated, + }); + resultSetIndex += 1; + rowCount += visibleRows; + truncated ||= setTruncated; + } + + if (databaseChangedBy(statementSql)) { + if (inTransaction) transactionDirty = true; + else shouldPersist = true; + } + shouldPersist ||= applyTransactionControl(control, wasInTransaction); + } finally { + statement.free(); + } + } + + if (!inTransaction && shouldPersist) { + persistDatabase(); + } + + return { + command: 'SQLite', + rowCount, + rowsAffected, + resultSetCount: resultSetIndex, + statementCount, + truncated, + inTransaction, + }; +} + +async function handleCommand(message) { + switch (message.type) { + case 'open': + return openDatabase(message.payload); + case 'queryAll': + return { rows: queryAll(message.payload.sql) }; + case 'execute': + return executeSql(message.requestId, message.payload); + case 'begin': + if (inTransaction) { + throw new Error('Ya existe una transacción SQLite activa.'); + } + assertDiskUnchanged(); + database.run('BEGIN TRANSACTION'); + inTransaction = true; + transactionDirty = false; + savepointDepth = 0; + savepointStartedTransaction = false; + return {}; + case 'commit': + if (!inTransaction) { + throw new Error('No hay una transacción SQLite activa.'); + } + if (transactionDirty) assertDiskUnchanged(); + database.run('COMMIT'); + inTransaction = false; + if (transactionDirty) persistDatabase(); + transactionDirty = false; + savepointDepth = 0; + savepointStartedTransaction = false; + return {}; + case 'rollback': + if (inTransaction) { + database.run('ROLLBACK'); + inTransaction = false; + } + transactionDirty = false; + savepointDepth = 0; + savepointStartedTransaction = false; + return {}; + case 'close': + if (database) { + if (inTransaction) { + database.run('ROLLBACK'); + inTransaction = false; + } + transactionDirty = false; + savepointDepth = 0; + savepointStartedTransaction = false; + database.close(); + database = null; + } + return {}; + default: + throw new Error(`Comando SQLite desconocido: ${message.type}`); + } +} + +parentPort.on('message', (message) => { + if (message.type === 'ack') { + const key = `${message.requestId}:${message.sequence}`; + acknowledgements.get(key)?.(); + acknowledgements.delete(key); + return; + } + + commandQueue = commandQueue.then(async () => { + try { + const result = await handleCommand(message); + parentPort.postMessage({ + type: 'response', + requestId: message.requestId, + result, + }); + } catch (error) { + parentPort.postMessage({ + type: 'error', + requestId: message.requestId, + error: serializeError(error), + }); + } + }); +}); diff --git a/src/core/errors.js b/src/core/errors.js new file mode 100644 index 0000000..ea41453 --- /dev/null +++ b/src/core/errors.js @@ -0,0 +1,44 @@ +'use strict'; + +class QueryCancelledError extends Error { + constructor(message = 'Consulta cancelada por el usuario.') { + super(message); + this.name = 'QueryCancelledError'; + this.code = 'SIMPLE_DB_CANCELLED'; + } +} + +function isCancellationError(error) { + const code = String(error?.code ?? '').toUpperCase(); + const message = String(error?.message ?? '').toLowerCase(); + + return ( + error instanceof QueryCancelledError || + ['SIMPLE_DB_CANCELLED', 'ECANCEL', '57014', 'SQLITE_INTERRUPT'].includes(code) || + message.includes('cancelled') || + message.includes('canceled') || + message.includes('cancelada') || + message.includes('user requested cancel') || + message.includes('ora-01013') + ); +} + +function normalizeDatabaseError(error, engineId) { + if (isCancellationError(error)) { + return new QueryCancelledError(); + } + + const normalized = new Error(error?.message || String(error)); + normalized.name = 'DatabaseError'; + normalized.code = error?.code || error?.number || error?.errorNum || undefined; + normalized.engineId = engineId; + normalized.position = error?.position || error?.lineNumber || error?.line || undefined; + normalized.originalError = error; + return normalized; +} + +module.exports = { + QueryCancelledError, + isCancellationError, + normalizeDatabaseError, +}; diff --git a/src/core/valueNormalizer.js b/src/core/valueNormalizer.js new file mode 100644 index 0000000..d082194 --- /dev/null +++ b/src/core/valueNormalizer.js @@ -0,0 +1,67 @@ +'use strict'; + +function truncateText(value, maxCharacters) { + if (value.length <= maxCharacters) { + return value; + } + + const removed = value.length - maxCharacters; + return `${value.slice(0, maxCharacters)}… [${removed} caracteres omitidos]`; +} + +function normalizeValue(value, maxCharacters = 10000) { + if (value === null || value === undefined) { + return null; + } + + if (Buffer.isBuffer(value) || value instanceof Uint8Array) { + const buffer = Buffer.from(value); + const preview = buffer.subarray(0, Math.min(buffer.length, 64)).toString('hex'); + const suffix = buffer.length > 64 ? '…' : ''; + return ``; + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (typeof value === 'string') { + return truncateText(value, maxCharacters); + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (typeof value === 'object') { + try { + const serialized = JSON.stringify(value, (_key, nestedValue) => + typeof nestedValue === 'bigint' ? nestedValue.toString() : nestedValue, + ); + return truncateText(serialized, maxCharacters); + } catch (_error) { + return truncateText(String(value), maxCharacters); + } + } + + return truncateText(String(value), maxCharacters); +} + +function normalizeRows(rows, columns, maxCharacters) { + return rows.map((row) => + columns.map((column, index) => { + const value = Array.isArray(row) ? row[index] : row[column.name]; + return normalizeValue(value, maxCharacters); + }), + ); +} + +module.exports = { + normalizeRows, + normalizeValue, + truncateText, +}; diff --git a/src/databaseEngines.js b/src/databaseEngines.js new file mode 100644 index 0000000..7840831 --- /dev/null +++ b/src/databaseEngines.js @@ -0,0 +1,87 @@ +'use strict'; + +const DATABASE_ENGINES = Object.freeze([ + Object.freeze({ + id: 'sqlite', + displayName: 'SQLite', + defaultPort: null, + color: '#4c9ed9', + objectGroups: ['tables', 'views', 'indexes', 'triggers'], + }), + Object.freeze({ + id: 'postgresql', + displayName: 'PostgreSQL', + defaultPort: 5432, + color: '#336791', + objectGroups: [ + 'tables', + 'views', + 'materializedViews', + 'procedures', + 'indexes', + 'triggers', + 'sequences', + 'types', + ], + }), + Object.freeze({ + id: 'mysql', + displayName: 'MySQL', + defaultPort: 3306, + color: '#00758f', + objectGroups: ['tables', 'views', 'procedures', 'indexes', 'triggers', 'events'], + }), + Object.freeze({ + id: 'sqlserver', + displayName: 'SQL Server', + defaultPort: 1433, + color: '#cc2927', + objectGroups: [ + 'tables', + 'views', + 'procedures', + 'indexes', + 'triggers', + 'sequences', + 'types', + 'synonyms', + ], + }), + Object.freeze({ + id: 'oracle', + displayName: 'Oracle', + defaultPort: 1521, + color: '#f80000', + objectGroups: [ + 'tables', + 'views', + 'materializedViews', + 'procedures', + 'packages', + 'indexes', + 'triggers', + 'sequences', + 'types', + 'synonyms', + ], + }), +]); + +const DATABASE_ENGINE_IDS = Object.freeze( + DATABASE_ENGINES.map((engine) => engine.id), +); + +function getDatabaseEngine(engineId) { + return DATABASE_ENGINES.find((engine) => engine.id === engineId); +} + +function isDatabaseEngineId(engineId) { + return DATABASE_ENGINE_IDS.includes(engineId); +} + +module.exports = { + DATABASE_ENGINES, + DATABASE_ENGINE_IDS, + getDatabaseEngine, + isDatabaseEngineId, +}; diff --git a/src/databaseEngines.ts b/src/databaseEngines.ts deleted file mode 100644 index df4be47..0000000 --- a/src/databaseEngines.ts +++ /dev/null @@ -1,34 +0,0 @@ -export type DatabaseEngineId = - | 'postgresql' - | 'mysql' - | 'sqlserver' - | 'oracle'; - -export interface DatabaseEngineDefinition { - readonly id: DatabaseEngineId; - readonly displayName: string; - readonly defaultPort: number; -} - -export const DATABASE_ENGINES: readonly DatabaseEngineDefinition[] = Object.freeze([ - { - id: 'postgresql', - displayName: 'PostgreSQL', - defaultPort: 5432, - }, - { - id: 'mysql', - displayName: 'MySQL', - defaultPort: 3306, - }, - { - id: 'sqlserver', - displayName: 'SQL Server', - defaultPort: 1433, - }, - { - id: 'oracle', - displayName: 'Oracle', - defaultPort: 1521, - }, -]); diff --git a/src/extension.js b/src/extension.js new file mode 100644 index 0000000..9d1e3b9 --- /dev/null +++ b/src/extension.js @@ -0,0 +1,650 @@ +'use strict'; + +const path = require('node:path'); +const vscode = require('vscode'); +const { ConnectionManager } = require('./managers/connectionManager'); +const { EditorSessionManager } = require('./managers/editorSessionManager'); +const { QueryRunner } = require('./services/queryRunner'); +const { ExportService } = require('./services/exportService'); +const { + OBJECT_LABELS, + alterTemplate, + createTemplate, + dropTemplate, + objectTypesForEngine, +} = require('./sql/ddlTemplates'); +const { ConnectionStore } = require('./storage/connectionStore'); +const { HistoryStore } = require('./storage/historyStore'); +const { ResultStore } = require('./storage/resultStore'); +const { promptConnection } = require('./ui/connectionForm'); +const { ConnectionsTreeProvider } = require('./views/connectionsTreeProvider'); +const { HistoryTreeProvider } = require('./views/historyTreeProvider'); +const { ResultPanel } = require('./views/resultPanel'); +const { getDatabaseEngine } = require('./databaseEngines'); + +let runtime = null; + +function historyConfiguration() { + const config = vscode.workspace.getConfiguration('simpleDb'); + return { + enabled: config.get('history.enabled', true), + maxEntries: Math.max(0, Number(config.get('history.maxEntries', 500))), + }; +} + +function exportConfiguration() { + const config = vscode.workspace.getConfiguration('simpleDb'); + return { + csvDelimiter: config.get('csvDelimiter', ';'), + csvProtectFormulaInjection: config.get('csvProtectFormulaInjection', true), + }; +} + +function profileForNode(connectionStore, node) { + return node?.profileId ? connectionStore.get(node.profileId) : null; +} + +async function pickProfile(connectionStore, title = 'Simple DB — Seleccionar conexión') { + const profiles = connectionStore.list(); + if (!profiles.length) { + throw new Error('No hay conexiones configuradas. Crea una conexión primero.'); + } + const pick = await vscode.window.showQuickPick( + profiles.map((profile) => ({ + label: `$(database) ${profile.name}`, + description: getDatabaseEngine(profile.engine)?.displayName || profile.engine, + profile, + })), + { title, ignoreFocusOut: true }, + ); + return pick?.profile || null; +} + +function databaseContext(profile, node) { + return ( + node?.database || + profile.database || + profile.serviceName || + (profile.engine === 'sqlite' ? 'main' : '') + ); +} + +function schemaContext(profile, node, database) { + if (node?.schema) return node.schema; + if (profile.engine === 'postgresql') return 'public'; + if (profile.engine === 'sqlserver') return 'dbo'; + if (profile.engine === 'oracle') return ''; + if (profile.engine === 'sqlite' || profile.engine === 'mysql') return database; + return ''; +} + +function groupObjectType(groupType) { + return { + tables: 'table', + views: 'view', + materializedViews: 'materializedView', + indexes: 'index', + triggers: 'trigger', + sequences: 'sequence', + packages: 'package', + types: 'type', + synonyms: 'synonym', + events: 'event', + }[groupType]; +} + +function effectiveObjectType(node) { + if (node?.objectType === 'procedure') { + return String(node.type || '').toUpperCase() === 'FUNCTION' + ? 'function' + : 'procedure'; + } + return node?.objectType; +} + +function ddlQualifiedName(connectionManager, profile, database, schema, name) { + return connectionManager.quoteTable( + profile.id, + profile.engine === 'sqlserver' ? null : database, + schema, + name, + ); +} + +function registerCommand(context, commandId, handler) { + const disposable = vscode.commands.registerCommand(commandId, async (...args) => { + try { + return await handler(...args); + } catch (error) { + vscode.window.showErrorMessage(`Simple DB: ${error.message}`); + return undefined; + } + }); + context.subscriptions.push(disposable); +} + +async function activate(context) { + const connectionStore = new ConnectionStore(context.globalState, context.secrets); + const connectionManager = new ConnectionManager(connectionStore); + const editorSessionManager = new EditorSessionManager( + connectionStore, + connectionManager, + ); + const historyStore = new HistoryStore(context.globalState, historyConfiguration); + const config = vscode.workspace.getConfiguration('simpleDb'); + const resultStore = new ResultStore( + path.join(context.globalStorageUri.fsPath, 'results'), + { + pageSize: config.get('resultPageSize', 500), + maxCellCharacters: config.get('maxCellCharacters', 10000), + }, + ); + await resultStore.initialize(); + const exportService = new ExportService(resultStore, exportConfiguration); + const resultPanel = new ResultPanel(resultStore, exportService); + const queryRunner = new QueryRunner({ + connectionStore, + connectionManager, + editorSessionManager, + resultStore, + resultPanel, + historyStore, + }); + const connectionsProvider = new ConnectionsTreeProvider( + connectionStore, + connectionManager, + ); + const historyProvider = new HistoryTreeProvider(historyStore); + + const connectionsView = vscode.window.createTreeView('simpleDb.connections', { + treeDataProvider: connectionsProvider, + showCollapseAll: true, + }); + const historyView = vscode.window.createTreeView('simpleDb.history', { + treeDataProvider: historyProvider, + }); + context.subscriptions.push( + connectionsView, + historyView, + connectionsProvider, + historyProvider, + editorSessionManager, + ); + + registerCommand(context, 'simpleDb.addConnection', async (node) => { + const form = await promptConnection({ engineId: node?.engineId }); + if (!form) return; + if (form.testBeforeSave) { + const result = await connectionManager.testProfile( + form.profile, + form.effectivePassword, + ); + vscode.window.showInformationMessage( + `Simple DB: conexión correcta (${result.elapsedMs} ms) · ${result.serverVersion}`, + ); + } + const saved = await connectionStore.save(form.profile, form.password); + connectionsProvider.refresh(); + vscode.window.showInformationMessage(`Simple DB: conexión "${saved.name}" guardada.`); + }); + + registerCommand(context, 'simpleDb.refreshConnections', () => { + connectionsProvider.refresh(); + }); + + registerCommand(context, 'simpleDb.connect', async (node) => { + const profile = profileForNode(connectionStore, node); + if (!profile) throw new Error('Conexión no encontrada.'); + const adapter = await connectionManager.connect(profile.id); + vscode.window.showInformationMessage( + `Simple DB: conectado a ${profile.name} · ${adapter.serverVersion}`, + ); + }); + + registerCommand(context, 'simpleDb.disconnect', async (node) => { + const profile = profileForNode(connectionStore, node); + if (!profile) throw new Error('Conexión no encontrada.'); + const transactionCount = connectionManager.transactionCount(profile.id); + const executionCount = connectionManager.executionCount(profile.id); + if (transactionCount > 0 || executionCount > 0) { + const details = [ + executionCount > 0 ? `${executionCount} consulta(s) activa(s)` : '', + transactionCount > 0 ? `${transactionCount} transacción(es) activa(s)` : '', + ].filter(Boolean).join(' y '); + const answer = await vscode.window.showWarningMessage( + `Hay ${details}. Desconectar cancelará las consultas y hará ROLLBACK de las transacciones.`, + { modal: true }, + 'Cancelar, hacer ROLLBACK y desconectar', + ); + if (!answer) return; + } + await connectionManager.disconnect(profile.id); + }); + + registerCommand(context, 'simpleDb.testConnection', async (node) => { + const profile = profileForNode(connectionStore, node); + if (!profile) throw new Error('Conexión no encontrada.'); + const result = await connectionManager.testConnection(profile.id); + vscode.window.showInformationMessage( + `Simple DB: ${profile.name} responde en ${result.elapsedMs} ms · ${result.serverVersion}`, + ); + }); + + registerCommand(context, 'simpleDb.editConnection', async (node) => { + const profile = profileForNode(connectionStore, node); + if (!profile) throw new Error('Conexión no encontrada.'); + const existingPassword = await connectionStore.getPassword(profile.id); + const form = await promptConnection({ + existingProfile: profile, + existingPassword, + }); + if (!form) return; + + if (form.testBeforeSave) { + const result = await connectionManager.testProfile( + form.profile, + form.effectivePassword, + ); + vscode.window.showInformationMessage( + `Simple DB: parámetros verificados en ${result.elapsedMs} ms · ${result.serverVersion}`, + ); + } + + if (connectionManager.isConnected(profile.id)) { + const transactions = connectionManager.transactionCount(profile.id); + const executions = connectionManager.executionCount(profile.id); + const text = transactions > 0 || executions > 0 + ? `Guardar requiere desconectar: se cancelarán ${executions} consulta(s) y se hará ROLLBACK de ${transactions} transacción(es).` + : 'Guardar requiere desconectar la conexión activa.'; + const answer = await vscode.window.showWarningMessage( + text, + { modal: true }, + transactions > 0 || executions > 0 + ? 'Guardar, cancelar y hacer ROLLBACK' + : 'Guardar y desconectar', + ); + if (!answer) return; + await connectionManager.disconnect(profile.id); + } + await connectionStore.save(form.profile, form.password, { + keepExistingPassword: form.password === undefined, + }); + connectionsProvider.refresh(); + }); + + registerCommand(context, 'simpleDb.deleteConnection', async (node) => { + const profile = profileForNode(connectionStore, node); + if (!profile) throw new Error('Conexión no encontrada.'); + const transactions = connectionManager.transactionCount(profile.id); + const executions = connectionManager.executionCount(profile.id); + const warning = transactions > 0 || executions > 0 + ? ` Se cancelarán ${executions} consulta(s) y se hará ROLLBACK de ${transactions} transacción(es) activa(s).` + : ''; + const answer = await vscode.window.showWarningMessage( + `¿Eliminar la conexión "${profile.name}"?${warning}`, + { modal: true }, + 'Eliminar', + ); + if (answer !== 'Eliminar') return; + await connectionManager.disconnect(profile.id); + await connectionStore.delete(profile.id); + connectionsProvider.refresh(); + }); + + registerCommand(context, 'simpleDb.newQuery', async (node) => { + const profile = profileForNode(connectionStore, node) || (await pickProfile(connectionStore)); + if (!profile) return; + const database = databaseContext(profile, node); + const schema = schemaContext(profile, node, database); + await editorSessionManager.createQuery(profile, '', { database, schema }); + }); + + registerCommand(context, 'simpleDb.changeEditorConnection', () => + editorSessionManager.changeActiveConnection(), + ); + + registerCommand(context, 'simpleDb.executeCurrent', () => queryRunner.run('current')); + registerCommand(context, 'simpleDb.executeSelection', () => + queryRunner.run('selection'), + ); + registerCommand(context, 'simpleDb.executeDocument', () => queryRunner.run('document')); + + registerCommand(context, 'simpleDb.cancelQuery', async () => { + const session = editorSessionManager.getActive(); + if (!session?.runningExecutionId) { + vscode.window.showInformationMessage('Simple DB: no hay una consulta activa que cancelar.'); + return; + } + await connectionManager.cancel(session.profileId, session.runningExecutionId); + }); + + registerCommand(context, 'simpleDb.beginTransaction', async () => { + const session = await editorSessionManager.ensureActiveSession(); + if (!session) return; + if (session.runningExecutionId) { + throw new Error('Espera o cancela la consulta activa antes de iniciar una transacción.'); + } + if (connectionManager.hasTransaction(session.profileId, session.id)) { + throw new Error('Ya existe una transacción activa en este editor.'); + } + await connectionManager.begin(session.profileId, session.id, { + database: session.database, + schema: session.schema, + }); + editorSessionManager.markTransactionNeedsRollback(session, false); + vscode.window.showInformationMessage('Simple DB: transacción iniciada.'); + }); + + registerCommand(context, 'simpleDb.commit', async () => { + const session = await editorSessionManager.ensureActiveSession(); + if (!session) return; + if (session.runningExecutionId) { + throw new Error('Espera o cancela la consulta activa antes de hacer COMMIT.'); + } + if (session.transactionNeedsRollback) { + throw new Error('La transacción tuvo un error. Ejecuta ROLLBACK antes de continuar.'); + } + await connectionManager.commit(session.profileId, session.id); + editorSessionManager.markTransactionNeedsRollback(session, false); + vscode.window.showInformationMessage('Simple DB: COMMIT completado.'); + }); + + registerCommand(context, 'simpleDb.rollback', async () => { + const session = await editorSessionManager.ensureActiveSession(); + if (!session) return; + if (session.runningExecutionId) { + throw new Error('Cancela o espera a que termine la consulta antes de hacer ROLLBACK.'); + } + await connectionManager.rollback(session.profileId, session.id); + editorSessionManager.markTransactionNeedsRollback(session, false); + vscode.window.showInformationMessage('Simple DB: ROLLBACK completado.'); + }); + + registerCommand(context, 'simpleDb.selectTable', async (node) => { + if (node?.kind !== 'object' || !['table', 'view', 'materializedView'].includes(node.objectType)) { + throw new Error('Selecciona una tabla o vista del explorador.'); + } + const profile = profileForNode(connectionStore, node); + await connectionManager.ensureConnected(profile.id); + const qualified = connectionManager.quoteTable( + profile.id, + node.database, + node.schema, + node.name, + ); + await editorSessionManager.createQuery(profile, `SELECT * FROM ${qualified};`, { + database: node.database, + schema: node.schema, + }); + }); + + registerCommand(context, 'simpleDb.showDefinition', async (node) => { + if (node?.kind !== 'object') throw new Error('Selecciona un objeto de base de datos.'); + const profile = profileForNode(connectionStore, node); + await connectionManager.ensureConnected(profile.id); + const definition = await connectionManager.getObjectDefinition( + profile.id, + node.database, + node.schema, + node.name, + node.objectType, + node, + ); + if (!definition) { + vscode.window.showWarningMessage( + `Simple DB: el servidor no devolvió la definición de ${node.name}.`, + ); + return; + } + await editorSessionManager.createQuery(profile, definition, { + database: node.database, + schema: node.schema, + }); + }); + + registerCommand(context, 'simpleDb.createObject', async (node) => { + const profile = profileForNode(connectionStore, node) || (await pickProfile(connectionStore)); + if (!profile) return; + await connectionManager.ensureConnected(profile.id); + const database = databaseContext(profile, node); + const schema = schemaContext(profile, node, database); + let objectType = groupObjectType(node?.groupType); + if (!objectType) { + const types = objectTypesForEngine(profile.engine); + const pick = await vscode.window.showQuickPick( + types.map((type) => ({ label: OBJECT_LABELS[type] || type, type })), + { title: `Simple DB — Crear objeto en ${profile.name}` }, + ); + objectType = pick?.type; + } else if (node?.groupType === 'procedures') { + objectType = undefined; + } + if (!objectType && node?.groupType === 'procedures') { + const routine = await vscode.window.showQuickPick( + [ + { label: 'Procedimiento', type: 'procedure' }, + { label: 'Función', type: 'function' }, + ], + { title: `Simple DB — Crear rutina en ${profile.name}` }, + ); + objectType = routine?.type; + } + if (!objectType) return; + + const name = await vscode.window.showInputBox({ + title: `Simple DB — Crear ${OBJECT_LABELS[objectType] || objectType}`, + prompt: 'Nombre del nuevo objeto', + ignoreFocusOut: true, + validateInput: (value) => (value.trim() ? null : 'El nombre es obligatorio.'), + }); + if (!name) return; + const qualified = ddlQualifiedName( + connectionManager, + profile, + database, + schema, + name.trim(), + ); + const template = createTemplate( + profile.engine, + objectType, + qualified, + (identifier) => connectionManager.quoteIdentifier(profile.id, identifier), + { + identifierName: connectionManager.quoteIdentifier(profile.id, name.trim()), + }, + ); + await editorSessionManager.createQuery(profile, template, { database, schema }); + }); + + registerCommand(context, 'simpleDb.alterObject', async (node) => { + if (node?.kind !== 'object') throw new Error('Selecciona un objeto de base de datos.'); + const profile = profileForNode(connectionStore, node); + await connectionManager.ensureConnected(profile.id); + const objectType = effectiveObjectType(node); + let qualified = ddlQualifiedName( + connectionManager, + profile, + node.database, + node.schema, + node.name, + ); + if ( + profile.engine === 'postgresql' && + ['procedure', 'function'].includes(objectType) && + node.signature !== undefined + ) { + qualified += `(${node.signature})`; + } + const tableQualifiedName = node.tableName + ? ddlQualifiedName( + connectionManager, + profile, + node.database, + node.schema, + node.tableName, + ) + : ''; + const template = alterTemplate(profile.engine, objectType, qualified, { + tableQualifiedName, + identifierName: connectionManager.quoteIdentifier(profile.id, node.name), + }); + await editorSessionManager.createQuery(profile, template, { + database: node.database, + schema: node.schema, + }); + }); + + registerCommand(context, 'simpleDb.dropObjectScript', async (node) => { + if (node?.kind !== 'object') throw new Error('Selecciona un objeto de base de datos.'); + const profile = profileForNode(connectionStore, node); + await connectionManager.ensureConnected(profile.id); + const objectType = effectiveObjectType(node); + let qualified = ddlQualifiedName( + connectionManager, + profile, + node.database, + node.schema, + node.name, + ); + if ( + profile.engine === 'postgresql' && + ['procedure', 'function'].includes(objectType) && + node.signature !== undefined + ) { + qualified += `(${node.signature})`; + } + const tableQualifiedName = node.tableName + ? ddlQualifiedName( + connectionManager, + profile, + node.database, + node.schema, + node.tableName, + ) + : ''; + const script = dropTemplate(profile.engine, objectType, qualified, { + tableQualifiedName, + identifierName: connectionManager.quoteIdentifier(profile.id, node.name), + objectName: node.name, + isConstraint: Boolean(node.isPrimaryKey || node.isUniqueConstraint), + constraintName: node.constraintName + ? connectionManager.quoteIdentifier(profile.id, node.constraintName) + : '', + }); + await editorSessionManager.createQuery(profile, script, { + database: node.database, + schema: node.schema, + }); + }); + + registerCommand(context, 'simpleDb.copyQualifiedName', async (node) => { + if (node?.kind !== 'object') throw new Error('Selecciona un objeto de base de datos.'); + const profile = profileForNode(connectionStore, node); + await connectionManager.ensureConnected(profile.id); + const qualified = connectionManager.quoteTable( + profile.id, + node.database, + node.schema, + node.name, + ); + await vscode.env.clipboard.writeText(qualified); + }); + + registerCommand(context, 'simpleDb.showHistory', () => + vscode.commands.executeCommand('simpleDb.history.focus'), + ); + + registerCommand(context, 'simpleDb.clearHistory', async () => { + if (!historyStore.list().length) return; + const answer = await vscode.window.showWarningMessage( + '¿Vaciar todo el historial local de consultas de Simple DB?', + { modal: true }, + 'Vaciar historial', + ); + if (answer === 'Vaciar historial') await historyStore.clear(); + }); + + const historyEntry = (node) => node?.entry || historyStore.get(node?.id); + + registerCommand(context, 'simpleDb.openHistoryEntry', async (node) => { + const entry = historyEntry(node); + if (!entry) throw new Error('La entrada de historial ya no existe.'); + const profile = connectionStore.get(entry.profileId); + if (!profile) throw new Error('La conexión asociada a esta consulta ya no existe.'); + await editorSessionManager.createQuery(profile, entry.sql, { + database: entry.database, + schema: entry.schema, + }); + }); + + registerCommand(context, 'simpleDb.copyHistoryEntry', async (node) => { + const entry = historyEntry(node); + if (!entry) throw new Error('La entrada de historial ya no existe.'); + await vscode.env.clipboard.writeText(entry.sql); + }); + + registerCommand(context, 'simpleDb.rerunHistoryEntry', async (node) => { + const entry = historyEntry(node); + if (!entry) throw new Error('La entrada de historial ya no existe.'); + const profile = connectionStore.get(entry.profileId); + if (!profile) throw new Error('La conexión asociada a esta consulta ya no existe.'); + await editorSessionManager.createQuery(profile, entry.sql, { + database: entry.database, + schema: entry.schema, + }); + await queryRunner.run('document'); + }); + + registerCommand(context, 'simpleDb.deleteHistoryEntry', async (node) => { + const entry = historyEntry(node); + if (entry) await historyStore.delete(entry.id); + }); + + const closeDisposable = vscode.workspace.onDidCloseTextDocument(async (document) => { + const session = editorSessionManager.detach(document); + if (!session) return; + try { + const hadTransaction = connectionManager.hasTransaction( + session.profileId, + session.id, + ); + if (session.runningExecutionId) { + await connectionManager.cancel(session.profileId, session.runningExecutionId); + } + if (connectionManager.hasTransaction(session.profileId, session.id)) { + await connectionManager.rollback(session.profileId, session.id); + } + if (hadTransaction) { + vscode.window.showWarningMessage( + 'Simple DB: se hizo ROLLBACK de la transacción al cerrar el editor SQL.', + ); + } + } catch (error) { + vscode.window.showErrorMessage( + `Simple DB: no se pudo cerrar la transacción del editor: ${error.message}`, + ); + } + }); + context.subscriptions.push(closeDisposable); + + runtime = { + connectionManager, + resultPanel, + resultStore, + }; +} + +async function deactivate() { + const active = runtime; + runtime = null; + if (!active) return; + active.resultPanel.dispose(); + await active.connectionManager.disconnectAll(); + await active.resultStore.dispose(); +} + +module.exports = { + activate, + deactivate, +}; diff --git a/src/extension.ts b/src/extension.ts deleted file mode 100644 index e9a8fef..0000000 --- a/src/extension.ts +++ /dev/null @@ -1,30 +0,0 @@ -import * as vscode from 'vscode'; - -import { ConnectionsTreeProvider } from './views/connectionsTreeProvider'; - -const CONNECTIONS_VIEW_ID = 'simpleDb.connections'; -const REFRESH_CONNECTIONS_COMMAND_ID = 'simpleDb.refreshConnections'; - -export function activate(context: vscode.ExtensionContext): void { - const connectionsTreeProvider = new ConnectionsTreeProvider(); - const connectionsTreeView = vscode.window.createTreeView(CONNECTIONS_VIEW_ID, { - treeDataProvider: connectionsTreeProvider, - showCollapseAll: true, - }); - const refreshConnectionsCommand = vscode.commands.registerCommand( - REFRESH_CONNECTIONS_COMMAND_ID, - () => { - connectionsTreeProvider.refresh(); - }, - ); - - context.subscriptions.push( - connectionsTreeProvider, - connectionsTreeView, - refreshConnectionsCommand, - ); -} - -export function deactivate(): void { - // Los recursos registrados se liberan mediante context.subscriptions. -} diff --git a/src/managers/connectionManager.js b/src/managers/connectionManager.js new file mode 100644 index 0000000..e408b6f --- /dev/null +++ b/src/managers/connectionManager.js @@ -0,0 +1,260 @@ +'use strict'; + +const { EventEmitter } = require('node:events'); +const { createAdapter } = require('../adapters/factory'); + +class ConnectionManager extends EventEmitter { + constructor(connectionStore) { + super(); + this.connectionStore = connectionStore; + this.adapters = new Map(); + this.statuses = new Map(); + this.connecting = new Map(); + } + + status(profileId) { + return ( + this.statuses.get(profileId) || { + state: 'disconnected', + serverVersion: '', + error: '', + } + ); + } + + _setStatus(profileId, state, details = {}) { + const previous = this.status(profileId); + this.statuses.set(profileId, { + state, + serverVersion: Object.hasOwn(details, 'serverVersion') + ? details.serverVersion + : previous.serverVersion || '', + error: Object.hasOwn(details, 'error') ? details.error : '', + }); + this.emit('change', profileId); + } + + isConnected(profileId) { + return this.adapters.get(profileId)?.isConnected() === true; + } + + async connect(profileId) { + if (this.isConnected(profileId)) { + return this.adapters.get(profileId); + } + if (this.connecting.has(profileId)) { + return this.connecting.get(profileId); + } + + const promise = this._connect(profileId); + this.connecting.set(profileId, promise); + try { + return await promise; + } finally { + this.connecting.delete(profileId); + } + } + + async _connect(profileId) { + const profile = this.connectionStore.get(profileId); + if (!profile) { + throw new Error('La conexión ya no existe.'); + } + this._setStatus(profileId, 'connecting'); + const password = await this.connectionStore.getPassword(profileId); + const adapter = createAdapter(profile, password); + try { + const serverVersion = await adapter.connect(); + this.adapters.set(profileId, adapter); + this._setStatus(profileId, 'connected', { serverVersion }); + return adapter; + } catch (error) { + this._setStatus(profileId, 'error', { error: error.message }); + throw error; + } + } + + async disconnect(profileId) { + const adapter = this.adapters.get(profileId); + if (adapter) { + try { + await adapter.disconnect(); + } finally { + this.adapters.delete(profileId); + } + } + this._setStatus(profileId, 'disconnected', { serverVersion: '' }); + } + + async disconnectAll() { + await Promise.allSettled( + [...this.adapters.keys()].map((profileId) => this.disconnect(profileId)), + ); + } + + async testProfile(profile, password) { + const adapter = createAdapter(profile, password || ''); + const started = Date.now(); + try { + const serverVersion = await adapter.connect(); + return { + serverVersion, + elapsedMs: Date.now() - started, + }; + } finally { + await adapter.disconnect().catch(() => {}); + } + } + + async testConnection(profileId) { + const profile = this.connectionStore.get(profileId); + if (!profile) { + throw new Error('La conexión ya no existe.'); + } + const password = await this.connectionStore.getPassword(profileId); + return this.testProfile(profile, password); + } + + async ensureConnected(profileId) { + return this.isConnected(profileId) + ? this.adapters.get(profileId) + : this.connect(profileId); + } + + getAdapter(profileId) { + const adapter = this.adapters.get(profileId); + if (!adapter?.isConnected()) { + throw new Error('La conexión no está conectada.'); + } + return adapter; + } + + async execute(profileId, sessionId, sql, options) { + const adapter = await this.ensureConnected(profileId); + return adapter.execute(sessionId, sql, options); + } + + async begin(profileId, sessionId, context = {}) { + const adapter = await this.ensureConnected(profileId); + await adapter.begin(sessionId, context); + this._setStatus(profileId, 'connected', { + serverVersion: this.status(profileId).serverVersion, + }); + this.emit('transaction', { profileId, sessionId, active: true }); + } + + async commit(profileId, sessionId) { + const adapter = this.getAdapter(profileId); + await adapter.commit(sessionId); + this.emit('transaction', { profileId, sessionId, active: false }); + this.emit('change', profileId); + } + + async rollback(profileId, sessionId) { + const adapter = this.getAdapter(profileId); + await adapter.rollback(sessionId); + this.emit('transaction', { profileId, sessionId, active: false }); + this.emit('change', profileId); + } + + hasTransaction(profileId, sessionId) { + return this.adapters.get(profileId)?.hasTransaction(sessionId) === true; + } + + transactionCount(profileId) { + return this.adapters.get(profileId)?.transactionCount() || 0; + } + + executionCount(profileId) { + return this.adapters.get(profileId)?.executionCount() || 0; + } + + async cancel(profileId, executionId) { + const adapter = this.adapters.get(profileId); + if (!adapter) { + return false; + } + const cancelled = await adapter.cancel(executionId); + this.emit('change', profileId); + return cancelled; + } + + notifyChanged(profileId) { + this.emit('change', profileId); + } + + async listDatabases(profileId) { + return this.getAdapter(profileId).listDatabases(); + } + + async listSchemas(profileId, database) { + return this.getAdapter(profileId).listSchemas(database); + } + + async listTables(profileId, database, schema) { + return this.getAdapter(profileId).listTables(database, schema); + } + + async listViews(profileId, database, schema) { + return this.getAdapter(profileId).listViews(database, schema); + } + + async listColumns(profileId, database, schema, objectName) { + return this.getAdapter(profileId).listColumns(database, schema, objectName); + } + + async listProcedures(profileId, database, schema) { + return this.getAdapter(profileId).listProcedures(database, schema); + } + + async listObjectGroup(profileId, database, schema, groupType) { + const adapter = this.getAdapter(profileId); + const methods = { + tables: 'listTables', + views: 'listViews', + materializedViews: 'listMaterializedViews', + procedures: 'listProcedures', + packages: 'listPackages', + indexes: 'listIndexes', + triggers: 'listTriggers', + sequences: 'listSequences', + types: 'listTypes', + synonyms: 'listSynonyms', + events: 'listEvents', + }; + const method = methods[groupType]; + if (!method || typeof adapter[method] !== 'function') { + return []; + } + return adapter[method](database, schema); + } + + async getObjectDefinition( + profileId, + database, + schema, + objectName, + objectType, + metadata = {}, + ) { + return this.getAdapter(profileId).getObjectDefinition( + database, + schema, + objectName, + objectType, + metadata, + ); + } + + quoteTable(profileId, database, schema, table) { + return this.getAdapter(profileId).quoteTable(database, schema, table); + } + + quoteIdentifier(profileId, identifier) { + return this.getAdapter(profileId).quoteIdentifier(identifier); + } +} + +module.exports = { + ConnectionManager, +}; diff --git a/src/managers/editorSessionManager.js b/src/managers/editorSessionManager.js new file mode 100644 index 0000000..760f9c2 --- /dev/null +++ b/src/managers/editorSessionManager.js @@ -0,0 +1,198 @@ +'use strict'; + +const { randomUUID } = require('node:crypto'); +const vscode = require('vscode'); +const { getDatabaseEngine } = require('../databaseEngines'); + +class EditorSessionManager { + constructor(connectionStore, connectionManager) { + this.connectionStore = connectionStore; + this.connectionManager = connectionManager; + this.sessions = new Map(); + this.statusBar = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 100, + ); + this.statusBar.command = 'simpleDb.changeEditorConnection'; + this.statusBar.tooltip = 'Simple DB: cambiar la conexión del editor SQL'; + + this.activeEditorDisposable = vscode.window.onDidChangeActiveTextEditor(() => { + this.refreshStatusBar(); + }); + this.transactionListener = () => this.refreshStatusBar(); + this.connectionManager.on('transaction', this.transactionListener); + this.connectionManager.on('change', this.transactionListener); + } + + _key(document) { + return document.uri.toString(); + } + + createSession(document, profile, options = {}) { + const session = { + id: randomUUID(), + documentUri: document.uri.toString(), + profileId: profile.id, + database: + options.database || + profile.database || + profile.serviceName || + (profile.engine === 'sqlite' ? 'main' : ''), + schema: options.schema || '', + runningExecutionId: null, + transactionNeedsRollback: false, + }; + this.sessions.set(this._key(document), session); + this.refreshStatusBar(); + return session; + } + + get(document) { + return document ? this.sessions.get(this._key(document)) : undefined; + } + + getActive() { + return this.get(vscode.window.activeTextEditor?.document); + } + + detach(document) { + const key = this._key(document); + const session = this.sessions.get(key); + this.sessions.delete(key); + this.refreshStatusBar(); + return session; + } + + async createQuery(profile, initialSql = '', options = {}) { + const heading = options.includeHeading === false + ? '' + : `-- Simple DB | ${getDatabaseEngine(profile.engine)?.displayName || profile.engine} | ${profile.name}\n\n`; + const document = await vscode.workspace.openTextDocument({ + language: 'sql', + content: `${heading}${initialSql}`, + }); + await vscode.window.showTextDocument(document, { preview: false }); + const session = this.createSession(document, profile, options); + return { document, session }; + } + + async ensureActiveSession() { + const editor = vscode.window.activeTextEditor; + if (!editor) { + throw new Error('No hay un editor SQL activo.'); + } + const existing = this.get(editor.document); + if (existing) { + return existing; + } + + const profiles = this.connectionStore.list(); + if (!profiles.length) { + throw new Error('Primero debes crear una conexión en Simple DB.'); + } + const pick = await vscode.window.showQuickPick( + profiles.map((profile) => ({ + label: `$(database) ${profile.name}`, + description: getDatabaseEngine(profile.engine)?.displayName || profile.engine, + profile, + })), + { + title: 'Simple DB — Vincular editor a una conexión', + placeHolder: 'Selecciona la conexión para este editor SQL', + }, + ); + if (!pick) { + return null; + } + return this.createSession(editor.document, pick.profile); + } + + async changeActiveConnection() { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + const session = this.get(editor.document); + if (session?.runningExecutionId) { + throw new Error('Cancela o espera a que termine la consulta antes de cambiar de conexión.'); + } + if (session && this.connectionManager.hasTransaction(session.profileId, session.id)) { + throw new Error('Haz COMMIT o ROLLBACK antes de cambiar la conexión del editor.'); + } + + const profiles = this.connectionStore.list(); + const pick = await vscode.window.showQuickPick( + profiles.map((profile) => ({ + label: profile.name, + description: getDatabaseEngine(profile.engine)?.displayName || profile.engine, + profile, + })), + { title: 'Simple DB — Cambiar conexión del editor' }, + ); + if (!pick) { + return; + } + + if (session) { + session.profileId = pick.profile.id; + session.database = + pick.profile.database || + pick.profile.serviceName || + (pick.profile.engine === 'sqlite' ? 'main' : ''); + session.schema = ''; + session.transactionNeedsRollback = false; + } else { + this.createSession(editor.document, pick.profile); + } + this.refreshStatusBar(); + } + + setRunning(session, executionId) { + session.runningExecutionId = executionId; + this.refreshStatusBar(); + } + + markTransactionNeedsRollback(session, value = true) { + session.transactionNeedsRollback = value; + this.refreshStatusBar(); + } + + refreshStatusBar() { + const session = this.getActive(); + if (!session) { + this.statusBar.hide(); + return; + } + const profile = this.connectionStore.get(session.profileId); + if (!profile) { + this.statusBar.text = '$(warning) Simple DB | conexión eliminada'; + this.statusBar.show(); + return; + } + + const engine = getDatabaseEngine(profile.engine)?.displayName || profile.engine; + const connected = this.connectionManager.isConnected(profile.id); + const transaction = this.connectionManager.hasTransaction(profile.id, session.id); + const mode = session.transactionNeedsRollback + ? 'TX requiere ROLLBACK' + : transaction + ? 'TX activa' + : 'Auto-commit'; + const running = session.runningExecutionId ? ' | $(sync~spin) Ejecutando' : ''; + const connectionIcon = connected ? '$(database)' : '$(circle-slash)'; + this.statusBar.text = `${connectionIcon} ${engine} | ${profile.name} | ${session.database || '-'} | ${mode}${running}`; + this.statusBar.show(); + } + + dispose() { + this.activeEditorDisposable.dispose(); + this.connectionManager.off('transaction', this.transactionListener); + this.connectionManager.off('change', this.transactionListener); + this.statusBar.dispose(); + this.sessions.clear(); + } +} + +module.exports = { + EditorSessionManager, +}; diff --git a/src/services/exportService.js b/src/services/exportService.js new file mode 100644 index 0000000..748aab9 --- /dev/null +++ b/src/services/exportService.js @@ -0,0 +1,149 @@ +'use strict'; + +const { once } = require('node:events'); +const fs = require('node:fs'); +const path = require('node:path'); +const vscode = require('vscode'); +const ExcelJS = require('exceljs'); + +function csvCell(value, delimiter, protectFormulas = true) { + if (value === null || value === undefined) return ''; + let text = String(value); + if (protectFormulas && /^[=+\-@\t\r]/.test(text)) { + text = `'${text}`; + } + if ( + text.includes(delimiter) || + text.includes('"') || + text.includes('\n') || + text.includes('\r') + ) { + return `"${text.replaceAll('"', '""')}"`; + } + return text; +} + +async function writeChunk(stream, chunk) { + if (!stream.write(chunk)) { + await once(stream, 'drain'); + } +} + +function uniqueJsonColumnNames(columns) { + const used = new Map(); + return columns.map((column, index) => { + const base = String(column.name || `column_${index + 1}`); + const count = used.get(base) || 0; + used.set(base, count + 1); + return count === 0 ? base : `${base}_${count + 1}`; + }); +} + +class ExportService { + constructor(resultStore, configurationProvider) { + this.resultStore = resultStore; + this.configurationProvider = configurationProvider; + } + + async chooseAndExport(executionId, setIndex, format) { + const metadata = this.resultStore.getMetadata(executionId); + const set = metadata?.sets?.[setIndex]; + if (!set || set.kind !== 'rows') { + throw new Error('El conjunto de resultados ya no está disponible para exportar.'); + } + + const extension = format === 'xlsx' ? 'xlsx' : format; + const defaultName = `simple-db-${new Date().toISOString().replaceAll(':', '-')}.${extension}`; + const uri = await vscode.window.showSaveDialog({ + title: `Exportar resultado como ${format.toUpperCase()}`, + defaultUri: vscode.Uri.file(path.join(process.cwd(), defaultName)), + filters: { + [format.toUpperCase()]: [extension], + }, + }); + if (!uri) return null; + + if (format === 'csv') { + await this._writeCsv(uri.fsPath, executionId, setIndex, set); + } else if (format === 'json') { + await this._writeJson(uri.fsPath, executionId, setIndex, set); + } else if (format === 'xlsx') { + await this._writeXlsx(uri.fsPath, executionId, setIndex, set); + } else { + throw new Error(`Formato de exportación no soportado: ${format}`); + } + return uri.fsPath; + } + + async _writeCsv(filename, executionId, setIndex, set) { + const configuration = this.configurationProvider(); + const delimiter = configuration.csvDelimiter || ';'; + const protectFormulas = configuration.csvProtectFormulaInjection !== false; + const stream = fs.createWriteStream(filename, { encoding: 'utf8' }); + try { + await writeChunk( + stream, + `\uFEFF${set.columns.map((column) => csvCell(column.name, delimiter, protectFormulas)).join(delimiter)}\n`, + ); + for await (const row of this.resultStore.iterateRows(executionId, setIndex)) { + await writeChunk( + stream, + `${row.map((value) => csvCell(value, delimiter, protectFormulas)).join(delimiter)}\n`, + ); + } + const finished = once(stream, 'finish'); + stream.end(); + await finished; + } catch (error) { + stream.destroy(); + throw error; + } + } + + async _writeJson(filename, executionId, setIndex, set) { + const names = uniqueJsonColumnNames(set.columns); + const stream = fs.createWriteStream(filename, { encoding: 'utf8' }); + try { + await writeChunk(stream, '[\n'); + let first = true; + for await (const row of this.resultStore.iterateRows(executionId, setIndex)) { + const object = Object.fromEntries( + names.map((name, index) => [name, row[index] ?? null]), + ); + const prefix = first ? ' ' : ',\n '; + await writeChunk(stream, `${prefix}${JSON.stringify(object)}`); + first = false; + } + await writeChunk(stream, '\n]\n'); + const finished = once(stream, 'finish'); + stream.end(); + await finished; + } catch (error) { + stream.destroy(); + throw error; + } + } + + async _writeXlsx(filename, executionId, setIndex, set) { + const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ + filename, + useStyles: true, + useSharedStrings: false, + }); + const sheet = workbook.addWorksheet('Resultado'); + const header = sheet.addRow(set.columns.map((column) => column.name)); + header.font = { bold: true }; + header.commit(); + for await (const row of this.resultStore.iterateRows(executionId, setIndex)) { + sheet.addRow(row).commit(); + } + sheet.commit(); + await workbook.commit(); + } +} + +module.exports = { + ExportService, + csvCell, + uniqueJsonColumnNames, +}; diff --git a/src/services/queryRunner.js b/src/services/queryRunner.js new file mode 100644 index 0000000..6a55617 --- /dev/null +++ b/src/services/queryRunner.js @@ -0,0 +1,395 @@ +'use strict'; + +const { randomUUID } = require('node:crypto'); +const vscode = require('vscode'); +const { QueryCancelledError, isCancellationError } = require('../core/errors'); +const { classifySqlSafety } = require('../sql/safety'); +const { findStatementAtOffset, splitSqlDocument } = require('../sql/sqlSplitter'); +const { detectTransactionControl } = require('../sql/transactionControl'); + +class QueryRunner { + constructor(options) { + this.connectionStore = options.connectionStore; + this.connectionManager = options.connectionManager; + this.editorSessionManager = options.editorSessionManager; + this.resultStore = options.resultStore; + this.resultPanel = options.resultPanel; + this.historyStore = options.historyStore; + } + + _configuration() { + const config = vscode.workspace.getConfiguration('simpleDb'); + return { + maxRows: Math.max(0, Math.trunc(Number(config.get('maxRows', 0)))), + pageSize: Math.max(1, Math.trunc(Number(config.get('resultPageSize', 500)))), + maxCellCharacters: Math.max( + 100, + Math.trunc(Number(config.get('maxCellCharacters', 10000))), + ), + confirmDestructiveQueries: config.get('confirmDestructiveQueries', true), + warnUnsafeDml: config.get('warnUnsafeDml', true), + }; + } + + _statements(editor, engineId, mode) { + const documentText = editor.document.getText(); + const selection = editor.selection; + if (mode === 'selection') { + if (selection.isEmpty) { + throw new Error('Selecciona primero el SQL que quieres ejecutar.'); + } + const selected = editor.document.getText(selection); + return { + statements: splitSqlDocument(selected, engineId), + historySql: selected, + baseOffset: editor.document.offsetAt(selection.start), + }; + } + + if (mode === 'current') { + if (!selection.isEmpty) { + const selected = editor.document.getText(selection); + return { + statements: splitSqlDocument(selected, engineId), + historySql: selected, + baseOffset: editor.document.offsetAt(selection.start), + }; + } + const offset = editor.document.offsetAt(selection.active); + const statement = findStatementAtOffset(documentText, engineId, offset); + return { + statements: statement ? [statement] : [], + historySql: statement?.sql || '', + baseOffset: 0, + }; + } + + return { + statements: splitSqlDocument(documentText, engineId), + historySql: documentText, + baseOffset: 0, + }; + } + + async _confirmSafety(statements, engineId, config) { + for (const statement of statements) { + const safety = classifySqlSafety(statement.sql, engineId); + let warning = ''; + if (safety.destructive && config.confirmDestructiveQueries) { + warning = `${safety.operation} puede eliminar o truncar objetos/datos. ¿Ejecutar?`; + } else if (safety.unsafeDml && config.warnUnsafeDml) { + warning = `${safety.operation} no contiene cláusula WHERE. Puede afectar a todas las filas. ¿Ejecutar?`; + } + if (!warning) continue; + const answer = await vscode.window.showWarningMessage( + warning, + { modal: true }, + 'Ejecutar', + ); + if (answer !== 'Ejecutar') return false; + } + return true; + } + + _sink(executionId, statementIndex, statementSql, nextGlobalSet) { + const localSets = new Map(); + const statementStarted = Date.now(); + const globalIndex = (localSetIndex) => { + if (!localSets.has(localSetIndex)) { + localSets.set(localSetIndex, nextGlobalSet()); + } + return localSets.get(localSetIndex); + }; + + return { + localSets, + start: async (localSetIndex, columns) => { + await this.resultStore.startSet( + executionId, + globalIndex(localSetIndex), + { statementIndex, columns, sql: statementSql, kind: 'rows' }, + ); + }, + rows: async (localSetIndex, rows) => { + await this.resultStore.appendRows( + executionId, + globalIndex(localSetIndex), + rows, + ); + }, + end: async (localSetIndex, metadata = {}) => { + await this.resultStore.finishSet( + executionId, + globalIndex(localSetIndex), + { + ...metadata, + durationMs: Date.now() - statementStarted, + }, + ); + }, + }; + } + + async run(mode = 'current') { + const editor = vscode.window.activeTextEditor; + if (!editor) { + throw new Error('No hay un editor SQL activo.'); + } + + const session = await this.editorSessionManager.ensureActiveSession(); + if (!session) return null; + if (session.runningExecutionId) { + throw new Error('Ya hay una consulta en ejecución en este editor. Cancélala o espera a que termine.'); + } + const profile = this.connectionStore.get(session.profileId); + if (!profile) throw new Error('La conexión vinculada al editor ya no existe.'); + + const extracted = this._statements(editor, profile.engine, mode); + if (extracted.statements.length === 0) { + throw new Error('No se ha encontrado ninguna sentencia SQL ejecutable.'); + } + const config = this._configuration(); + if (!(await this._confirmSafety(extracted.statements, profile.engine, config))) { + return null; + } + + this.resultStore.configure?.({ + pageSize: config.pageSize, + maxCellCharacters: config.maxCellCharacters, + }); + + const executionId = randomUUID(); + const started = Date.now(); + await this.resultStore.createExecution(executionId, { + profileId: profile.id, + connectionName: profile.name, + engine: profile.engine, + database: session.database || '', + schema: session.schema || '', + maxRows: config.maxRows, + pageSize: config.pageSize, + statementCount: extracted.statements.length, + }); + + this.editorSessionManager.setRunning(session, executionId); + let globalSetIndex = 0; + const nextGlobalSet = () => { + const value = globalSetIndex; + globalSetIndex += 1; + return value; + }; + let failure = null; + let cancelled = false; + let schemaChanged = false; + let currentStatement = null; + let affectedRowsTotal = 0; + + try { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Simple DB — ${profile.name}`, + cancellable: true, + }, + async (progress, token) => { + const cancelDisposable = token.onCancellationRequested(() => { + cancelled = true; + void this.connectionManager.cancel(profile.id, executionId); + }); + try { + for (let index = 0; index < extracted.statements.length; index += 1) { + if (cancelled) break; + const statement = extracted.statements[index]; + currentStatement = statement; + progress.report({ + message: `sentencia ${index + 1}/${extracted.statements.length}`, + }); + + const safety = classifySqlSafety(statement.sql, profile.engine); + if (['CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'RENAME'].includes(safety.operation)) { + schemaChanged = true; + } + + const sink = this._sink( + executionId, + index, + statement.sql, + nextGlobalSet, + ); + const statementStarted = Date.now(); + const transactionControl = detectTransactionControl( + statement.sql, + profile.engine, + ); + if (transactionControl === 'begin') { + await this.connectionManager.begin(profile.id, session.id, { + database: session.database, + schema: session.schema, + beginSql: ['postgresql', 'mysql'].includes(profile.engine) + ? statement.sql + : '', + }); + this.editorSessionManager.markTransactionNeedsRollback(session, false); + await this.resultStore.addMessageSet(executionId, nextGlobalSet(), { + statementIndex: index, + sql: statement.sql, + durationMs: Date.now() - statementStarted, + message: 'BEGIN completado. Transacción activa en este editor.', + }); + continue; + } + if ( + ['commit', 'rollback'].includes(transactionControl) && + this.connectionManager.hasTransaction(profile.id, session.id) + ) { + if ( + transactionControl === 'commit' && + session.transactionNeedsRollback + ) { + throw new Error( + 'La transacción tuvo un error. Ejecuta ROLLBACK antes de COMMIT.', + ); + } + await this.connectionManager[transactionControl]( + profile.id, + session.id, + ); + this.editorSessionManager.markTransactionNeedsRollback(session, false); + await this.resultStore.addMessageSet(executionId, nextGlobalSet(), { + statementIndex: index, + sql: statement.sql, + durationMs: Date.now() - statementStarted, + message: `${transactionControl.toUpperCase()} completado.`, + }); + continue; + } + let timedOut = false; + let timeoutHandle = null; + const queryTimeoutMs = Math.max(0, Number(profile.queryTimeoutMs || 0)); + if (queryTimeoutMs > 0) { + timeoutHandle = setTimeout(() => { + timedOut = true; + void this.connectionManager.cancel(profile.id, executionId); + }, queryTimeoutMs); + } + + try { + const result = await this.connectionManager.execute( + profile.id, + session.id, + statement.sql, + { + executionId, + maxRows: config.maxRows, + pageSize: config.pageSize, + database: session.database, + schema: session.schema, + sink, + }, + ); + affectedRowsTotal += Number(result.rowsAffected || 0); + if (result.resultSetCount === 0) { + const setIndex = nextGlobalSet(); + const affectedRows = Number(result.rowsAffected || 0); + await this.resultStore.addMessageSet(executionId, setIndex, { + statementIndex: index, + sql: statement.sql, + affectedRows, + durationMs: Date.now() - statementStarted, + message: `${result.command || safety.operation || 'SQL'} ejecutado correctamente${affectedRows ? ` · ${affectedRows} filas afectadas` : ''}.`, + }); + } + } catch (error) { + if (timedOut) { + const timeoutError = new Error( + `La consulta superó el tiempo máximo configurado (${queryTimeoutMs} ms).`, + ); + timeoutError.code = 'SIMPLE_DB_TIMEOUT'; + throw timeoutError; + } + throw error; + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle); + } + } + if (cancelled) throw new QueryCancelledError(); + } finally { + cancelDisposable.dispose(); + } + }, + ); + } catch (error) { + cancelled = cancelled || isCancellationError(error); + failure = error; + if (this.connectionManager.hasTransaction(profile.id, session.id)) { + this.editorSessionManager.markTransactionNeedsRollback(session, true); + } + if (!cancelled && currentStatement) { + const start = editor.document.positionAt( + extracted.baseOffset + currentStatement.start, + ); + const end = editor.document.positionAt( + extracted.baseOffset + currentStatement.end, + ); + editor.selection = new vscode.Selection(start, end); + editor.revealRange(new vscode.Range(start, end), vscode.TextEditorRevealType.InCenterIfOutsideViewport); + } + await this.resultStore.addMessageSet(executionId, nextGlobalSet(), { + kind: cancelled ? 'message' : 'error', + message: cancelled ? 'Consulta cancelada.' : `Error: ${error.message}`, + }); + } finally { + this.editorSessionManager.setRunning(session, null); + } + + const durationMs = Date.now() - started; + const status = failure ? (cancelled ? 'cancelled' : 'error') : 'success'; + const resultSnapshot = this.resultStore.getMetadata(executionId); + const totalRows = (resultSnapshot?.sets || []).reduce( + (sum, set) => sum + Number(set?.rowCount || 0), + 0, + ); + const totalAffectedRows = affectedRowsTotal; + const finalMetadata = await this.resultStore.finalizeExecution(executionId, { + status, + durationMs, + error: failure && !cancelled ? failure.message : '', + totalRows, + affectedRows: totalAffectedRows, + }); + + await this.historyStore.add({ + engine: profile.engine, + profileId: profile.id, + connectionName: profile.name, + database: session.database, + schema: session.schema, + sql: extracted.historySql, + durationMs, + rows: totalRows, + affectedRows: totalAffectedRows, + success: !failure, + error: failure?.message || '', + }); + + if (schemaChanged) this.connectionManager.notifyChanged(profile.id); + if (editor.document.isClosed) { + await this.resultStore.deleteExecution(executionId); + return finalMetadata; + } + await this.resultPanel.show(executionId); + if (!failure) { + vscode.window.setStatusBarMessage( + `$(check) Simple DB: ${totalRows} filas${totalAffectedRows ? ` · ${totalAffectedRows} afectadas` : ''} · ${durationMs} ms`, + 5000, + ); + } else if (!cancelled) { + vscode.window.showErrorMessage(`Simple DB: ${failure.message}`); + } + return finalMetadata; + } +} + +module.exports = { + QueryRunner, +}; diff --git a/src/sql/ddlTemplates.js b/src/sql/ddlTemplates.js new file mode 100644 index 0000000..12f9a23 --- /dev/null +++ b/src/sql/ddlTemplates.js @@ -0,0 +1,320 @@ +'use strict'; + +const OBJECT_TYPES_BY_ENGINE = Object.freeze({ + sqlite: ['table', 'view', 'index', 'trigger'], + postgresql: [ + 'table', + 'view', + 'materializedView', + 'procedure', + 'function', + 'index', + 'trigger', + 'sequence', + 'type', + ], + mysql: ['table', 'view', 'procedure', 'function', 'index', 'trigger', 'event'], + sqlserver: [ + 'table', + 'view', + 'procedure', + 'function', + 'index', + 'trigger', + 'sequence', + 'type', + 'synonym', + ], + oracle: [ + 'table', + 'view', + 'materializedView', + 'procedure', + 'function', + 'package', + 'index', + 'trigger', + 'sequence', + 'type', + 'synonym', + ], +}); + +const OBJECT_LABELS = Object.freeze({ + table: 'Tabla', + view: 'Vista', + materializedView: 'Vista materializada', + procedure: 'Procedimiento', + function: 'Función', + package: 'Package', + index: 'Índice', + trigger: 'Trigger', + sequence: 'Secuencia', + type: 'Tipo', + synonym: 'Sinónimo', + event: 'Evento', +}); + +function objectTypesForEngine(engineId) { + return [...(OBJECT_TYPES_BY_ENGINE[engineId] || [])]; +} + +function createTemplate( + engineId, + objectType, + qualifiedName, + quoteIdentifier, + options = {}, +) { + const q = quoteIdentifier; + const tablePlaceholder = q('tabla'); + const columnPlaceholder = q('columna'); + + if (objectType === 'table') { + const identity = { + postgresql: 'BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY', + mysql: 'BIGINT AUTO_INCREMENT PRIMARY KEY', + sqlserver: 'BIGINT IDENTITY(1,1) PRIMARY KEY', + oracle: 'NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY', + sqlite: 'INTEGER PRIMARY KEY', + }[engineId] || 'INTEGER PRIMARY KEY'; + const textType = engineId === 'oracle' ? 'VARCHAR2(255)' : 'VARCHAR(255)'; + return `CREATE TABLE ${qualifiedName} (\n ${q('id')} ${identity},\n ${q('nombre')} ${textType} NOT NULL\n);`; + } + + if (objectType === 'view') { + const create = ['postgresql', 'oracle'].includes(engineId) + ? 'CREATE OR REPLACE VIEW' + : engineId === 'sqlserver' + ? 'CREATE OR ALTER VIEW' + : 'CREATE VIEW'; + return `${create} ${qualifiedName} AS\nSELECT *\nFROM ${tablePlaceholder};`; + } + + if (objectType === 'materializedView') { + return `CREATE MATERIALIZED VIEW ${qualifiedName} AS\nSELECT *\nFROM ${tablePlaceholder};`; + } + + if (objectType === 'index') { + // PostgreSQL, MySQL y SQL Server determinan el esquema del índice por la + // tabla y no aceptan un nombre de índice cualificado en CREATE INDEX. + const indexName = ['postgresql', 'mysql', 'sqlserver'].includes(engineId) + ? options.identifierName || qualifiedName + : qualifiedName; + return `CREATE INDEX ${indexName}\nON ${tablePlaceholder} (${columnPlaceholder});`; + } + + if (objectType === 'sequence') { + if (engineId === 'sqlserver') { + return `CREATE SEQUENCE ${qualifiedName}\n AS BIGINT\n START WITH 1\n INCREMENT BY 1;`; + } + return `CREATE SEQUENCE ${qualifiedName}\n START WITH 1\n INCREMENT BY 1;`; + } + + if (objectType === 'procedure' || objectType === 'function') { + const isFunction = objectType === 'function'; + if (engineId === 'postgresql') { + return isFunction + ? `CREATE OR REPLACE FUNCTION ${qualifiedName}()\nRETURNS void\nLANGUAGE plpgsql\nAS $$\nBEGIN\n -- implementación\nEND;\n$$;` + : `CREATE OR REPLACE PROCEDURE ${qualifiedName}()\nLANGUAGE plpgsql\nAS $$\nBEGIN\n -- implementación\nEND;\n$$;`; + } + if (engineId === 'mysql') { + return isFunction + ? `DELIMITER $$\nCREATE FUNCTION ${qualifiedName}()\nRETURNS INT\nDETERMINISTIC\nBEGIN\n RETURN 0;\nEND$$\nDELIMITER ;` + : `DELIMITER $$\nCREATE PROCEDURE ${qualifiedName}()\nBEGIN\n -- implementación\nEND$$\nDELIMITER ;`; + } + if (engineId === 'sqlserver') { + return isFunction + ? `CREATE OR ALTER FUNCTION ${qualifiedName}()\nRETURNS INT\nAS\nBEGIN\n RETURN 0;\nEND;` + : `CREATE OR ALTER PROCEDURE ${qualifiedName}\nAS\nBEGIN\n SET NOCOUNT ON;\n -- implementación\nEND;`; + } + if (engineId === 'oracle') { + return isFunction + ? `CREATE OR REPLACE FUNCTION ${qualifiedName}\nRETURN NUMBER\nAS\nBEGIN\n RETURN 0;\nEND;\n/` + : `CREATE OR REPLACE PROCEDURE ${qualifiedName}\nAS\nBEGIN\n NULL;\nEND;\n/`; + } + } + + if (objectType === 'package' && engineId === 'oracle') { + return `CREATE OR REPLACE PACKAGE ${qualifiedName} AS\n PROCEDURE ejemplo;\nEND;\n/\n\nCREATE OR REPLACE PACKAGE BODY ${qualifiedName} AS\n PROCEDURE ejemplo AS\n BEGIN\n NULL;\n END ejemplo;\nEND;\n/`; + } + + if (objectType === 'trigger') { + if (engineId === 'sqlite') { + return `CREATE TRIGGER ${qualifiedName}\nAFTER INSERT ON ${tablePlaceholder}\nBEGIN\n -- sentencia SQL;\n SELECT 1;\nEND;`; + } + if (engineId === 'postgresql') { + const triggerName = options.identifierName || qualifiedName; + return `-- PostgreSQL requiere una función de trigger existente.\nCREATE TRIGGER ${triggerName}\nBEFORE INSERT ON ${tablePlaceholder}\nFOR EACH ROW\nEXECUTE FUNCTION ${q('funcion_trigger')}();`; + } + if (engineId === 'mysql') { + return `DELIMITER $$\nCREATE TRIGGER ${qualifiedName}\nBEFORE INSERT ON ${tablePlaceholder}\nFOR EACH ROW\nBEGIN\n -- implementación\nEND$$\nDELIMITER ;`; + } + if (engineId === 'sqlserver') { + return `CREATE OR ALTER TRIGGER ${qualifiedName}\nON ${tablePlaceholder}\nAFTER INSERT\nAS\nBEGIN\n SET NOCOUNT ON;\n -- implementación\nEND;`; + } + if (engineId === 'oracle') { + return `CREATE OR REPLACE TRIGGER ${qualifiedName}\nBEFORE INSERT ON ${tablePlaceholder}\nFOR EACH ROW\nBEGIN\n NULL;\nEND;\n/`; + } + } + + if (objectType === 'type') { + if (engineId === 'postgresql') { + return `CREATE TYPE ${qualifiedName} AS (\n ${q('valor')} TEXT\n);`; + } + if (engineId === 'sqlserver') { + return `CREATE TYPE ${qualifiedName} AS TABLE (\n ${q('valor')} NVARCHAR(255) NOT NULL\n);`; + } + if (engineId === 'oracle') { + return `CREATE OR REPLACE TYPE ${qualifiedName} AS OBJECT (\n ${q('valor')} VARCHAR2(255)\n);\n/`; + } + } + + if (objectType === 'synonym') { + return `CREATE SYNONYM ${qualifiedName} FOR ${tablePlaceholder};`; + } + + if (objectType === 'event' && engineId === 'mysql') { + return `DELIMITER $$\nCREATE EVENT ${qualifiedName}\nON SCHEDULE EVERY 1 DAY\nDO\nBEGIN\n -- implementación\nEND$$\nDELIMITER ;`; + } + + throw new Error(`No hay plantilla CREATE para ${engineId}/${objectType}.`); +} + +function alterTemplate(engineId, objectType, qualifiedName, options = {}) { + const identifierName = options.identifierName || qualifiedName; + const tableQualifiedName = options.tableQualifiedName || ''; + + if (objectType === 'table') { + return `ALTER TABLE ${qualifiedName}\n -- ADD COLUMN ...;`; + } + if (objectType === 'view') { + if (engineId === 'sqlserver') return `CREATE OR ALTER VIEW ${qualifiedName} AS\n-- SELECT ...;`; + if (['postgresql', 'oracle'].includes(engineId)) { + return `CREATE OR REPLACE VIEW ${qualifiedName} AS\n-- SELECT ...;`; + } + if (engineId === 'mysql') return `ALTER VIEW ${qualifiedName} AS\n-- SELECT ...;`; + if (engineId === 'sqlite') { + return `-- SQLite no implementa ALTER VIEW. Conserva la definición, elimina la vista y créala de nuevo.\n-- DROP VIEW ${qualifiedName};\n-- CREATE VIEW ${qualifiedName} AS SELECT ...;`; + } + } + if (objectType === 'materializedView') { + if (engineId === 'oracle') { + return `ALTER MATERIALIZED VIEW ${qualifiedName}\n -- COMPILE;`; + } + return `ALTER MATERIALIZED VIEW ${qualifiedName}\n -- RENAME TO nuevo_nombre;`; + } + if (objectType === 'procedure' || objectType === 'function') { + const keyword = objectType.toUpperCase(); + if (engineId === 'postgresql') return `ALTER ${keyword} ${qualifiedName}()\n -- ...;`; + if (engineId === 'sqlserver') return `CREATE OR ALTER ${keyword} ${qualifiedName}\nAS\n-- implementación`; + if (engineId === 'oracle') return `ALTER ${keyword} ${qualifiedName} COMPILE;`; + if (engineId === 'mysql') return `ALTER ${keyword} ${qualifiedName}\n -- COMMENT '...';`; + } + if (objectType === 'package' && engineId === 'oracle') { + return `ALTER PACKAGE ${qualifiedName} COMPILE PACKAGE;\nALTER PACKAGE ${qualifiedName} COMPILE BODY;`; + } + if (objectType === 'sequence') { + return `ALTER SEQUENCE ${qualifiedName}\n -- INCREMENT BY 1;`; + } + if (objectType === 'event' && engineId === 'mysql') return `ALTER EVENT ${qualifiedName}\n -- ...;`; + if (objectType === 'type') { + if (engineId === 'postgresql') { + return `ALTER TYPE ${qualifiedName}\n -- ADD VALUE 'nuevo_valor';`; + } + if (engineId === 'oracle') { + return `ALTER TYPE ${qualifiedName} COMPILE;`; + } + if (engineId === 'sqlserver') { + return `-- SQL Server no ofrece ALTER TYPE para cambiar la definición de un tipo.\n-- Revisa dependencias y utiliza DROP TYPE + CREATE TYPE para ${qualifiedName}.`; + } + } + if (objectType === 'index') { + if (engineId === 'postgresql') { + return `ALTER INDEX ${qualifiedName}\n -- RENAME TO nuevo_nombre;`; + } + if (engineId === 'oracle') { + return `ALTER INDEX ${qualifiedName} REBUILD;`; + } + if (engineId === 'sqlserver' && tableQualifiedName) { + return `ALTER INDEX ${identifierName} ON ${tableQualifiedName}\n REBUILD;`; + } + if (engineId === 'mysql' && tableQualifiedName) { + return `-- MySQL 8+: cambia visibilidad; otros cambios se hacen con ALTER TABLE.\nALTER TABLE ${tableQualifiedName}\n ALTER INDEX ${identifierName} VISIBLE;`; + } + return `-- Este motor no ofrece un ALTER INDEX genérico seguro para ${qualifiedName}.\n-- Consulta la definición y utiliza la operación específica del motor.`; + } + if (objectType === 'trigger') { + if (engineId === 'postgresql' && tableQualifiedName) { + return `ALTER TRIGGER ${identifierName} ON ${tableQualifiedName}\n -- RENAME TO nuevo_nombre;`; + } + if (engineId === 'oracle') { + return `ALTER TRIGGER ${qualifiedName} COMPILE;\n-- ALTER TRIGGER ${qualifiedName} ENABLE;`; + } + if (engineId === 'sqlserver' && tableQualifiedName) { + return `ALTER TRIGGER ${qualifiedName}\nON ${tableQualifiedName}\nAFTER INSERT\nAS\nBEGIN\n SET NOCOUNT ON;\n -- implementación\nEND;`; + } + if (['mysql', 'sqlite'].includes(engineId)) { + return `-- ${engineId === 'mysql' ? 'MySQL' : 'SQLite'} no permite alterar el cuerpo de un trigger directamente.\n-- Consulta su definición y utiliza DROP TRIGGER + CREATE TRIGGER para ${qualifiedName}.`; + } + return `-- Edita la definición del trigger ${qualifiedName} con la sintaxis específica del motor.`; + } + if (objectType === 'synonym') { + return `-- Los sinónimos se sustituyen normalmente con DROP + CREATE.\n-- Objeto: ${qualifiedName}`; + } + return `-- ALTER ${OBJECT_LABELS[objectType] || objectType}: ${qualifiedName}`; +} + +function dropTemplate(engineId, objectType, qualifiedName, options = {}) { + const keywords = { + table: 'TABLE', + view: 'VIEW', + materializedView: 'MATERIALIZED VIEW', + procedure: 'PROCEDURE', + function: 'FUNCTION', + package: 'PACKAGE', + index: 'INDEX', + trigger: 'TRIGGER', + sequence: 'SEQUENCE', + type: 'TYPE', + synonym: 'SYNONYM', + event: 'EVENT', + }; + const keyword = keywords[objectType]; + if (!keyword) throw new Error(`Tipo de objeto no válido: ${objectType}`); + + if (objectType === 'index' && options.tableQualifiedName) { + if (engineId === 'mysql' && String(options.objectName || '').toUpperCase() === 'PRIMARY') { + return `ALTER TABLE ${options.tableQualifiedName} DROP PRIMARY KEY;`; + } + if (engineId === 'sqlserver' && options.isConstraint) { + return `ALTER TABLE ${options.tableQualifiedName} DROP CONSTRAINT ${options.identifierName || qualifiedName};`; + } + if (engineId === 'postgresql' && options.constraintName) { + return `ALTER TABLE ${options.tableQualifiedName} DROP CONSTRAINT IF EXISTS ${options.constraintName};`; + } + if (['mysql', 'sqlserver'].includes(engineId)) { + return `DROP INDEX ${options.identifierName || qualifiedName} ON ${options.tableQualifiedName};`; + } + } + + if ( + objectType === 'trigger' && + engineId === 'postgresql' && + options.tableQualifiedName + ) { + return `DROP TRIGGER IF EXISTS ${options.identifierName || qualifiedName} ON ${options.tableQualifiedName};`; + } + + const supportsIfExists = ['sqlite', 'postgresql', 'mysql'].includes(engineId); + return `DROP ${keyword}${supportsIfExists ? ' IF EXISTS' : ''} ${qualifiedName};`; +} + +module.exports = { + OBJECT_LABELS, + OBJECT_TYPES_BY_ENGINE, + alterTemplate, + createTemplate, + dropTemplate, + objectTypesForEngine, +}; diff --git a/src/sql/safety.js b/src/sql/safety.js new file mode 100644 index 0000000..4e2c05c --- /dev/null +++ b/src/sql/safety.js @@ -0,0 +1,36 @@ +'use strict'; + +const { createCodeMask } = require('./sqlSplitter'); + +function classifySqlSafety(sql, engineId) { + const code = createCodeMask(sql, { + dollarQuotes: engineId === 'postgresql', + hashComments: engineId === 'mysql', + oracleQuotes: engineId === 'oracle', + }); + const normalized = code.replace(/\s+/g, ' ').trim(); + const destructive = /\b(?:DROP|TRUNCATE)\b/i.test(normalized); + const updateWithoutWhere = /^UPDATE\b/i.test(normalized) && !/\bWHERE\b/i.test(normalized); + const deleteWithoutWhere = /^DELETE\b/i.test(normalized) && !/\bWHERE\b/i.test(normalized); + + return { + destructive, + unsafeDml: updateWithoutWhere || deleteWithoutWhere, + operation: normalized.match(/^([A-Za-z]+)/)?.[1]?.toUpperCase() || 'SQL', + }; +} + +function looksLikeRowQuery(sql, engineId) { + const code = createCodeMask(sql, { + dollarQuotes: engineId === 'postgresql', + hashComments: engineId === 'mysql', + oracleQuotes: engineId === 'oracle', + }) + .replace(/^\s+/, ''); + return /^(?:SELECT|WITH|SHOW|DESCRIBE|DESC|EXPLAIN|VALUES|TABLE|PRAGMA)\b/i.test(code); +} + +module.exports = { + classifySqlSafety, + looksLikeRowQuery, +}; diff --git a/src/sql/sqlSplitter.js b/src/sql/sqlSplitter.js new file mode 100644 index 0000000..214759e --- /dev/null +++ b/src/sql/sqlSplitter.js @@ -0,0 +1,433 @@ +'use strict'; + +function createCodeMask(sql, options = {}) { + const mask = sql.split(''); + const allowDollarQuotes = options.dollarQuotes === true; + const allowHashComments = options.hashComments === true; + const allowOracleQuotes = options.oracleQuotes === true; + let i = 0; + let blockDepth = 0; + let lineComment = false; + let quote = null; + let dollarTag = null; + let oracleQuoteEnd = null; + + const hide = (index) => { + if (mask[index] !== '\n' && mask[index] !== '\r') { + mask[index] = ' '; + } + }; + + while (i < sql.length) { + const char = sql[i]; + const next = sql[i + 1]; + + if (lineComment) { + hide(i); + if (char === '\n') { + lineComment = false; + } + i += 1; + continue; + } + + if (blockDepth > 0) { + hide(i); + if (char === '/' && next === '*') { + hide(i + 1); + blockDepth += 1; + i += 2; + } else if (char === '*' && next === '/') { + hide(i + 1); + blockDepth -= 1; + i += 2; + } else { + i += 1; + } + continue; + } + + if (dollarTag !== null) { + if (sql.startsWith(dollarTag, i)) { + for (let j = 0; j < dollarTag.length; j += 1) { + hide(i + j); + } + i += dollarTag.length; + dollarTag = null; + } else { + hide(i); + i += 1; + } + continue; + } + + if (oracleQuoteEnd !== null) { + if (sql.startsWith(oracleQuoteEnd, i)) { + for (let j = 0; j < oracleQuoteEnd.length; j += 1) hide(i + j); + i += oracleQuoteEnd.length; + oracleQuoteEnd = null; + } else { + hide(i); + i += 1; + } + continue; + } + + if (quote !== null) { + hide(i); + + if (quote === ']' && char === ']' && next === ']') { + hide(i + 1); + i += 2; + continue; + } + + if (char === quote) { + if ((quote === "'" || quote === '"' || quote === '`') && next === quote) { + hide(i + 1); + i += 2; + continue; + } + + let backslashes = 0; + for (let j = i - 1; j >= 0 && sql[j] === '\\'; j -= 1) { + backslashes += 1; + } + if (backslashes % 2 === 0) { + quote = null; + } + } + + i += 1; + continue; + } + + if (char === '-' && next === '-') { + hide(i); + hide(i + 1); + lineComment = true; + i += 2; + continue; + } + + if (allowHashComments && char === '#') { + hide(i); + lineComment = true; + i += 1; + continue; + } + + if (char === '/' && next === '*') { + hide(i); + hide(i + 1); + blockDepth = 1; + i += 2; + continue; + } + + if (char === "'" || char === '"' || char === '`') { + hide(i); + quote = char; + i += 1; + continue; + } + + if (allowOracleQuotes && /[qQ]/.test(char) && next === "'" && i + 2 < sql.length) { + const opening = sql[i + 2]; + const closing = { '[': ']', '{': '}', '(': ')', '<': '>' }[opening] || opening; + hide(i); + hide(i + 1); + hide(i + 2); + oracleQuoteEnd = `${closing}'`; + i += 3; + continue; + } + + if (char === '[') { + hide(i); + quote = ']'; + i += 1; + continue; + } + + if (allowDollarQuotes && char === '$') { + const match = sql.slice(i).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/); + if (match) { + dollarTag = match[0]; + for (let j = 0; j < dollarTag.length; j += 1) { + hide(i + j); + } + i += dollarTag.length; + continue; + } + } + + i += 1; + } + + return mask.join(''); +} + +function isExecutableSql(sql, options = {}) { + return createCodeMask(sql, options).trim().length > 0; +} + +function trimSegment(source, start, end, removeSuffix = 0) { + let left = start; + let right = Math.max(start, end - removeSuffix); + + while (left < right && /\s/.test(source[left])) { + left += 1; + } + while (right > left && /\s/.test(source[right - 1])) { + right -= 1; + } + + return { sql: source.slice(left, right), start: left, end: right }; +} + +function splitWithDelimiter(source, delimiter = ';', options = {}) { + const allowDollarQuotes = options.dollarQuotes === true; + const keepDelimiter = options.keepDelimiter ?? delimiter === ';'; + const mask = createCodeMask(source, { + dollarQuotes: allowDollarQuotes, + hashComments: options.hashComments === true, + oracleQuotes: options.oracleQuotes === true, + }); + const segments = []; + let start = 0; + let i = 0; + + while (i <= source.length - delimiter.length) { + if ( + mask.startsWith(delimiter, i) && + source.startsWith(delimiter, i) + ) { + const rawEnd = i + delimiter.length; + const segment = trimSegment( + source, + start, + rawEnd, + keepDelimiter ? 0 : delimiter.length, + ); + if (isExecutableSql(segment.sql, options)) { + segments.push(segment); + } + start = rawEnd; + i = rawEnd; + continue; + } + i += 1; + } + + const tail = trimSegment(source, start, source.length); + if (isExecutableSql(tail.sql, options)) { + segments.push(tail); + } + return segments; +} + +function lineRanges(source) { + const ranges = []; + let start = 0; + for (let i = 0; i <= source.length; i += 1) { + if (i === source.length || source[i] === '\n') { + ranges.push({ start, end: i, next: Math.min(i + 1, source.length) }); + start = i + 1; + } + } + return ranges; +} + +function splitSqlServer(source) { + const mask = createCodeMask(source); + const segments = []; + let batchStart = 0; + + for (const line of lineRanges(source)) { + const codeLine = mask.slice(line.start, line.end).trim(); + const match = codeLine.match(/^GO(?:\s+(\d+))?$/i); + if (!match) { + continue; + } + + const batch = trimSegment(source, batchStart, line.start); + if (isExecutableSql(batch.sql)) { + const repetitions = Math.max(1, Number.parseInt(match[1] || '1', 10)); + for (let count = 0; count < repetitions; count += 1) { + segments.push({ ...batch }); + } + } + batchStart = line.next; + } + + const tail = trimSegment(source, batchStart, source.length); + if (isExecutableSql(tail.sql)) { + segments.push(tail); + } + return segments; +} + +function splitMysql(source) { + const mask = createCodeMask(source, { hashComments: true }); + const segments = []; + let delimiter = ';'; + let sectionStart = 0; + + const addSection = (start, end, currentDelimiter) => { + const section = source.slice(start, end); + for (const statement of splitWithDelimiter(section, currentDelimiter, { + keepDelimiter: currentDelimiter === ';', + hashComments: true, + })) { + segments.push({ + sql: statement.sql, + start: statement.start + start, + end: statement.end + start, + }); + } + }; + + for (const line of lineRanges(source)) { + const codeLine = mask.slice(line.start, line.end).trim(); + const match = codeLine.match(/^DELIMITER\s+(\S+)\s*$/i); + if (!match) { + continue; + } + + addSection(sectionStart, line.start, delimiter); + delimiter = match[1]; + sectionStart = line.next; + } + + addSection(sectionStart, source.length, delimiter); + return segments; +} + +function looksLikeOraclePlsql(source) { + const code = createCodeMask(source).trimStart(); + return /^(?:DECLARE\b|BEGIN\b|CREATE\s+(?:OR\s+REPLACE\s+)?(?:PROCEDURE|FUNCTION|PACKAGE(?:\s+BODY)?|TRIGGER|TYPE(?:\s+BODY)?)\b)/i.test( + code, + ); +} + +function splitOracleChunk(source, offset, segments) { + const trimmed = trimSegment(source, 0, source.length); + if (!isExecutableSql(trimmed.sql)) { + return; + } + + if (looksLikeOraclePlsql(trimmed.sql)) { + segments.push({ + sql: trimmed.sql, + start: offset + trimmed.start, + end: offset + trimmed.end, + }); + return; + } + + for (const statement of splitWithDelimiter(source, ';', { oracleQuotes: true })) { + segments.push({ + sql: statement.sql, + start: offset + statement.start, + end: offset + statement.end, + }); + } +} + +function splitOracle(source) { + const mask = createCodeMask(source, { oracleQuotes: true }); + const segments = []; + let chunkStart = 0; + + for (const line of lineRanges(source)) { + if (mask.slice(line.start, line.end).trim() !== '/') { + continue; + } + splitOracleChunk(source.slice(chunkStart, line.start), chunkStart, segments); + chunkStart = line.next; + } + + splitOracleChunk(source.slice(chunkStart), chunkStart, segments); + return segments; +} + +function splitSqlite(source) { + const raw = splitWithDelimiter(source, ';'); + const result = []; + let trigger = null; + + for (const segment of raw) { + const code = createCodeMask(segment.sql).trim(); + if (trigger === null && /^CREATE\s+(?:TEMP(?:ORARY)?\s+)?TRIGGER\b/i.test(code)) { + trigger = { ...segment }; + if (/\bEND\s*;?\s*$/i.test(code)) { + result.push(trigger); + trigger = null; + } + continue; + } + + if (trigger !== null) { + trigger.sql = source.slice(trigger.start, segment.end); + trigger.end = segment.end; + if (/\bEND\s*;?\s*$/i.test(code)) { + result.push(trigger); + trigger = null; + } + continue; + } + + result.push(segment); + } + + if (trigger !== null) { + result.push(trigger); + } + return result; +} + +function splitSqlDocument(source, engineId) { + switch (engineId) { + case 'postgresql': + return splitWithDelimiter(source, ';', { dollarQuotes: true }); + case 'mysql': + return splitMysql(source); + case 'sqlserver': + return splitSqlServer(source); + case 'oracle': + return splitOracle(source); + case 'sqlite': + return splitSqlite(source); + default: + return splitWithDelimiter(source, ';'); + } +} + +function findStatementAtOffset(source, engineId, offset) { + const statements = splitSqlDocument(source, engineId); + if (statements.length === 0) { + return null; + } + + const exact = statements.find( + (statement) => offset >= statement.start && offset <= statement.end, + ); + if (exact) { + return exact; + } + + const previous = [...statements] + .reverse() + .find((statement) => statement.end < offset); + return previous || statements[0]; +} + +module.exports = { + createCodeMask, + findStatementAtOffset, + isExecutableSql, + looksLikeOraclePlsql, + splitSqlDocument, + splitWithDelimiter, +}; diff --git a/src/sql/transactionControl.js b/src/sql/transactionControl.js new file mode 100644 index 0000000..e6a250c --- /dev/null +++ b/src/sql/transactionControl.js @@ -0,0 +1,45 @@ +'use strict'; + +const { createCodeMask } = require('./sqlSplitter'); + +function normalizeControlSql(sql, engineId) { + return createCodeMask(sql, { + dollarQuotes: engineId === 'postgresql', + hashComments: engineId === 'mysql', + oracleQuotes: engineId === 'oracle', + }) + .replace(/;\s*$/, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function detectTransactionControl(sql, engineId) { + // El worker SQLite sincroniza también BEGIN IMMEDIATE/EXCLUSIVE y SAVEPOINT. + if (engineId === 'sqlite') return null; + const code = normalizeControlSql(sql, engineId); + + if (engineId !== 'oracle') { + const beginPatterns = { + // PostgreSQL permite modos como ISOLATION LEVEL / READ ONLY y MySQL + // añade READ ONLY/WRITE o WITH CONSISTENT SNAPSHOT. Se reserva primero + // la conexión física y el adaptador ejecuta la sentencia original. + postgresql: /^(?:BEGIN(?:\s+(?:WORK|TRANSACTION))?(?:\s+.+)?|START\s+TRANSACTION(?:\s+.+)?)$/i, + mysql: /^(?:BEGIN(?:\s+WORK)?|START\s+TRANSACTION(?:\s+.+)?)$/i, + sqlserver: /^BEGIN\s+TRAN(?:SACTION)?(?:\s+[A-Za-z_][A-Za-z0-9_@$#]*)?$/i, + }; + if (beginPatterns[engineId]?.test(code)) return 'begin'; + } + + if (/^COMMIT(?:\s+(?:WORK|TRAN(?:SACTION)?)(?:\s+[A-Za-z_][A-Za-z0-9_@$#]*)?)?$/i.test(code)) { + return 'commit'; + } + if (/^ROLLBACK(?:\s+(?:WORK|TRAN(?:SACTION)?)(?:\s+[A-Za-z_][A-Za-z0-9_@$#]*)?)?$/i.test(code)) { + return 'rollback'; + } + return null; +} + +module.exports = { + detectTransactionControl, + normalizeControlSql, +}; diff --git a/src/storage/connectionStore.js b/src/storage/connectionStore.js new file mode 100644 index 0000000..84132a7 --- /dev/null +++ b/src/storage/connectionStore.js @@ -0,0 +1,157 @@ +'use strict'; + +const { randomUUID } = require('node:crypto'); +const { isDatabaseEngineId } = require('../databaseEngines'); + +const PROFILES_KEY = 'simpleDb.connectionProfiles.v1'; +const PASSWORD_PREFIX = 'simpleDb.password.'; + +function sanitizeProfile(profile) { + const common = { + id: String(profile.id), + name: String(profile.name).trim(), + engine: String(profile.engine), + connectTimeoutMs: Number(profile.connectTimeoutMs || 15000), + queryTimeoutMs: Number(profile.queryTimeoutMs ?? 300000), + createdAt: profile.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + if (profile.engine === 'sqlite') { + return { + ...common, + filePath: String(profile.filePath || ''), + readOnly: Boolean(profile.readOnly), + }; + } + + const network = { + ...common, + host: String(profile.host || ''), + port: Number(profile.port || 0), + user: String(profile.user || ''), + database: String(profile.database || ''), + }; + + if (profile.engine === 'postgresql' || profile.engine === 'mysql') { + return { + ...network, + ssl: Boolean(profile.ssl), + trustServerCertificate: Boolean(profile.trustServerCertificate), + }; + } + + if (profile.engine === 'sqlserver') { + return { + ...network, + encrypt: profile.encrypt !== false, + trustServerCertificate: Boolean(profile.trustServerCertificate), + instanceName: String(profile.instanceName || ''), + }; + } + + if (profile.engine === 'oracle') { + return { + ...network, + serviceName: String(profile.serviceName || profile.database || ''), + connectString: String(profile.connectString || ''), + }; + } + + return network; +} + +class ConnectionStore { + constructor(globalState, secrets) { + this.globalState = globalState; + this.secrets = secrets; + } + + list() { + const profiles = this.globalState.get(PROFILES_KEY, []); + if (!Array.isArray(profiles)) { + return []; + } + + return profiles + .filter( + (profile) => + profile && + typeof profile.id === 'string' && + typeof profile.name === 'string' && + isDatabaseEngineId(profile.engine), + ) + .map((profile) => ({ ...profile })); + } + + get(profileId) { + return this.list().find((profile) => profile.id === profileId); + } + + async getPassword(profileId) { + return (await this.secrets.get(`${PASSWORD_PREFIX}${profileId}`)) || ''; + } + + async save(input, password, options = {}) { + const existing = input.id ? this.get(input.id) : undefined; + const id = existing?.id || input.id || randomUUID(); + const profile = sanitizeProfile({ + ...existing, + ...input, + id, + createdAt: existing?.createdAt || input.createdAt, + }); + + if (!profile.name) { + throw new Error('El nombre de la conexión es obligatorio.'); + } + if (!isDatabaseEngineId(profile.engine)) { + throw new Error(`Motor de base de datos no válido: ${profile.engine}`); + } + if (profile.engine === 'sqlite' && !profile.filePath) { + throw new Error('La ruta del archivo SQLite es obligatoria.'); + } + + const profiles = this.list(); + const duplicate = profiles.find( + (candidate) => + candidate.id !== id && + candidate.name.localeCompare(profile.name, undefined, { + sensitivity: 'accent', + }) === 0, + ); + if (duplicate) { + throw new Error(`Ya existe una conexión llamada "${profile.name}".`); + } + + const index = profiles.findIndex((candidate) => candidate.id === id); + if (index >= 0) { + profiles[index] = profile; + } else { + profiles.push(profile); + } + await this.globalState.update(PROFILES_KEY, profiles); + + if (profile.engine === 'sqlite') { + await this.secrets.delete(`${PASSWORD_PREFIX}${id}`); + } else if (password !== undefined && password !== null && password !== '') { + await this.secrets.store(`${PASSWORD_PREFIX}${id}`, String(password)); + } else if (!existing && !options.keepExistingPassword) { + await this.secrets.store(`${PASSWORD_PREFIX}${id}`, ''); + } + + return { ...profile }; + } + + async delete(profileId) { + const profiles = this.list().filter((profile) => profile.id !== profileId); + await this.globalState.update(PROFILES_KEY, profiles); + await this.secrets.delete(`${PASSWORD_PREFIX}${profileId}`); + } +} + +module.exports = { + ConnectionStore, + PROFILES_KEY, + sanitizeProfile, +}; diff --git a/src/storage/historyStore.js b/src/storage/historyStore.js new file mode 100644 index 0000000..1539170 --- /dev/null +++ b/src/storage/historyStore.js @@ -0,0 +1,88 @@ +'use strict'; + +const { randomUUID } = require('node:crypto'); + +const HISTORY_KEY = 'simpleDb.queryHistory.v1'; +const MAX_SQL_CHARACTERS = 100000; + +class HistoryStore { + constructor(globalState, configurationProvider) { + this.globalState = globalState; + this.configurationProvider = configurationProvider; + this.listeners = new Set(); + } + + _configuration() { + return this.configurationProvider(); + } + + list() { + const history = this.globalState.get(HISTORY_KEY, []); + return Array.isArray(history) ? history.map((entry) => ({ ...entry })) : []; + } + + get(entryId) { + return this.list().find((entry) => entry.id === entryId); + } + + onDidChange(listener) { + this.listeners.add(listener); + return { dispose: () => this.listeners.delete(listener) }; + } + + _emit() { + for (const listener of this.listeners) { + listener(); + } + } + + async add(entry) { + const config = this._configuration(); + if (!config.enabled || config.maxEntries <= 0) { + return null; + } + + const sql = String(entry.sql || ''); + const stored = { + id: randomUUID(), + timestamp: entry.timestamp || new Date().toISOString(), + engine: entry.engine, + profileId: entry.profileId, + connectionName: entry.connectionName, + database: entry.database || '', + schema: entry.schema || '', + sql: + sql.length > MAX_SQL_CHARACTERS + ? `${sql.slice(0, MAX_SQL_CHARACTERS)}\n-- [Historial truncado]` + : sql, + durationMs: Number(entry.durationMs || 0), + rows: Number(entry.rows || 0), + affectedRows: Number(entry.affectedRows || 0), + success: entry.success !== false, + error: entry.error ? String(entry.error) : '', + }; + + const history = [stored, ...this.list()].slice(0, config.maxEntries); + await this.globalState.update(HISTORY_KEY, history); + this._emit(); + return stored; + } + + async delete(entryId) { + await this.globalState.update( + HISTORY_KEY, + this.list().filter((entry) => entry.id !== entryId), + ); + this._emit(); + } + + async clear() { + await this.globalState.update(HISTORY_KEY, []); + this._emit(); + } +} + +module.exports = { + HISTORY_KEY, + HistoryStore, +}; diff --git a/src/storage/resultStore.js b/src/storage/resultStore.js new file mode 100644 index 0000000..76f514e --- /dev/null +++ b/src/storage/resultStore.js @@ -0,0 +1,245 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { normalizeRows } = require('../core/valueNormalizer'); + +class ResultStore { + constructor(rootPath, options = {}) { + this.rootPath = rootPath; + this.pageSize = Math.max(1, Math.trunc(Number(options.pageSize || 500))); + this.maxCellCharacters = Math.max( + 1, + Math.trunc(Number(options.maxCellCharacters || 10000)), + ); + this.executions = new Map(); + this.setStates = new Map(); + } + + configure(options = {}) { + if (Number(options.pageSize) > 0) { + this.pageSize = Math.max(1, Math.trunc(Number(options.pageSize))); + } + if (Number(options.maxCellCharacters) > 0) { + this.maxCellCharacters = Math.max( + 1, + Math.trunc(Number(options.maxCellCharacters)), + ); + } + } + + async initialize() { + await fs.mkdir(this.rootPath, { recursive: true }); + const entries = await fs.readdir(this.rootPath, { withFileTypes: true }); + await Promise.all( + entries.map((entry) => + fs.rm(path.join(this.rootPath, entry.name), { + recursive: true, + force: true, + }), + ), + ); + } + + _executionPath(executionId) { + return path.join(this.rootPath, executionId); + } + + _setKey(executionId, setIndex) { + return `${executionId}:${setIndex}`; + } + + _setPath(executionId, setIndex) { + return path.join(this._executionPath(executionId), `set-${setIndex}`); + } + + async createExecution(executionId, metadata) { + const execution = { + id: executionId, + createdAt: new Date().toISOString(), + status: 'running', + durationMs: 0, + error: '', + ...metadata, + sets: [], + }; + this.executions.set(executionId, execution); + await fs.mkdir(this._executionPath(executionId), { recursive: true }); + return execution; + } + + async startSet(executionId, setIndex, metadata = {}) { + const execution = this.executions.get(executionId); + if (!execution) { + throw new Error(`Resultado desconocido: ${executionId}`); + } + + const columns = (metadata.columns || []).map((column, index) => ({ + name: String(column.name || `Column ${index + 1}`), + type: String(column.type || ''), + nullable: column.nullable, + })); + const setMetadata = { + index: setIndex, + statementIndex: Number(metadata.statementIndex || 0), + kind: metadata.kind || (columns.length > 0 ? 'rows' : 'message'), + columns, + rowCount: 0, + affectedRows: 0, + pages: 0, + truncated: false, + durationMs: 0, + message: metadata.message || '', + sql: metadata.sql || '', + }; + + execution.sets[setIndex] = setMetadata; + const state = { + metadata: setMetadata, + buffer: [], + nextPage: 0, + }; + this.setStates.set(this._setKey(executionId, setIndex), state); + await fs.mkdir(this._setPath(executionId, setIndex), { recursive: true }); + return setMetadata; + } + + async _flushPage(executionId, setIndex, state) { + if (state.buffer.length === 0) { + return; + } + + const pageIndex = state.nextPage; + const filename = path.join( + this._setPath(executionId, setIndex), + `page-${String(pageIndex).padStart(6, '0')}.json`, + ); + const rows = state.buffer; + state.buffer = []; + await fs.writeFile(filename, JSON.stringify(rows), 'utf8'); + state.nextPage += 1; + state.metadata.pages = state.nextPage; + } + + async appendRows(executionId, setIndex, rows) { + if (!rows || rows.length === 0) { + return; + } + const state = this.setStates.get(this._setKey(executionId, setIndex)); + if (!state) { + throw new Error(`Conjunto de resultados desconocido: ${setIndex}`); + } + + const normalized = normalizeRows( + rows, + state.metadata.columns, + this.maxCellCharacters, + ); + for (const row of normalized) { + state.buffer.push(row); + state.metadata.rowCount += 1; + if (state.buffer.length >= this.pageSize) { + await this._flushPage(executionId, setIndex, state); + } + } + } + + async finishSet(executionId, setIndex, metadata = {}) { + const state = this.setStates.get(this._setKey(executionId, setIndex)); + if (!state) { + return; + } + await this._flushPage(executionId, setIndex, state); + Object.assign(state.metadata, { + affectedRows: Number(metadata.affectedRows || state.metadata.affectedRows || 0), + truncated: Boolean(metadata.truncated), + durationMs: Number(metadata.durationMs || state.metadata.durationMs || 0), + message: metadata.message || state.metadata.message || '', + }); + this.setStates.delete(this._setKey(executionId, setIndex)); + } + + async addMessageSet(executionId, setIndex, metadata = {}) { + await this.startSet(executionId, setIndex, { + ...metadata, + columns: [], + kind: metadata.kind || 'message', + }); + await this.finishSet(executionId, setIndex, metadata); + } + + async finalizeExecution(executionId, metadata = {}) { + const execution = this.executions.get(executionId); + if (!execution) { + return null; + } + + const pending = [...this.setStates.entries()].filter(([key]) => + key.startsWith(`${executionId}:`), + ); + for (const [key] of pending) { + const setIndex = Number(key.slice(key.lastIndexOf(':') + 1)); + await this.finishSet(executionId, setIndex); + } + + Object.assign(execution, metadata); + await fs.writeFile( + path.join(this._executionPath(executionId), 'metadata.json'), + JSON.stringify(execution, null, 2), + 'utf8', + ); + return execution; + } + + getMetadata(executionId) { + const execution = this.executions.get(executionId); + return execution ? JSON.parse(JSON.stringify(execution)) : null; + } + + async getPage(executionId, setIndex, pageIndex) { + const execution = this.executions.get(executionId); + const set = execution?.sets?.[setIndex]; + if (!set || pageIndex < 0 || pageIndex >= set.pages) { + return []; + } + const filename = path.join( + this._setPath(executionId, setIndex), + `page-${String(pageIndex).padStart(6, '0')}.json`, + ); + return JSON.parse(await fs.readFile(filename, 'utf8')); + } + + async *iterateRows(executionId, setIndex) { + const execution = this.executions.get(executionId); + const set = execution?.sets?.[setIndex]; + if (!set) { + return; + } + for (let pageIndex = 0; pageIndex < set.pages; pageIndex += 1) { + const rows = await this.getPage(executionId, setIndex, pageIndex); + for (const row of rows) { + yield row; + } + } + } + + async deleteExecution(executionId) { + this.executions.delete(executionId); + for (const key of [...this.setStates.keys()]) { + if (key.startsWith(`${executionId}:`)) { + this.setStates.delete(key); + } + } + await fs.rm(this._executionPath(executionId), { recursive: true, force: true }); + } + + async dispose() { + for (const executionId of [...this.executions.keys()]) { + await this.deleteExecution(executionId); + } + } +} + +module.exports = { + ResultStore, +}; diff --git a/src/test/adapterFactory.test.js b/src/test/adapterFactory.test.js new file mode 100644 index 0000000..6037c32 --- /dev/null +++ b/src/test/adapterFactory.test.js @@ -0,0 +1,41 @@ +'use strict'; + +const { createAdapter } = require('../adapters/factory'); + +const profiles = [ + { id: 'sqlite', engine: 'sqlite', filePath: '/tmp/test.db' }, + { id: 'postgresql', engine: 'postgresql', host: 'localhost', database: 'db', user: 'u' }, + { id: 'mysql', engine: 'mysql', host: 'localhost', database: 'db', user: 'u' }, + { id: 'sqlserver', engine: 'sqlserver', host: 'localhost', database: 'db', user: 'u' }, + { id: 'oracle', engine: 'oracle', host: 'localhost', serviceName: 'svc', user: 'u' }, +]; + +describe('adapter factory contract', () => { + it.each(profiles)('carga el adaptador $engine y cumple el contrato común', (profile) => { + const adapter = createAdapter(profile, 'password'); + for (const method of [ + 'connect', + 'disconnect', + 'execute', + 'begin', + 'commit', + 'rollback', + 'cancel', + 'listDatabases', + 'listSchemas', + 'listTables', + 'listViews', + 'listColumns', + 'listProcedures', + 'getObjectDefinition', + 'quoteIdentifier', + 'quoteTable', + ]) { + expect(typeof adapter[method], `${profile.engine}.${method}`).toBe('function'); + } + }); + + it('rechaza motores desconocidos', () => { + expect(() => createAdapter({ engine: 'unknown' }, '')).toThrow(/no soportado/i); + }); +}); diff --git a/src/test/connectionStore.test.js b/src/test/connectionStore.test.js new file mode 100644 index 0000000..daa2f3d --- /dev/null +++ b/src/test/connectionStore.test.js @@ -0,0 +1,77 @@ +'use strict'; + +const { ConnectionStore, PROFILES_KEY } = require('../storage/connectionStore'); + +function memoryMemento() { + const values = new Map(); + return { + get: (key, fallback) => (values.has(key) ? values.get(key) : fallback), + update: async (key, value) => values.set(key, value), + values, + }; +} + +function memorySecrets() { + const values = new Map(); + return { + get: async (key) => values.get(key), + store: async (key, value) => values.set(key, value), + delete: async (key) => values.delete(key), + values, + }; +} + +describe('ConnectionStore', () => { + it('guarda el perfil separado de la contraseña', async () => { + const state = memoryMemento(); + const secrets = memorySecrets(); + const store = new ConnectionStore(state, secrets); + const profile = await store.save( + { + name: 'Producción PG', + engine: 'postgresql', + host: 'db.internal', + port: 5432, + database: 'app', + user: 'reader', + ssl: true, + }, + 'super-secret', + ); + + await expect(store.getPassword(profile.id)).resolves.toBe('super-secret'); + expect(JSON.stringify(state.values.get(PROFILES_KEY))).not.toContain('super-secret'); + expect(store.get(profile.id)).not.toHaveProperty('password'); + }); + + it('conserva un secreto existente al editar si no se proporciona contraseña', async () => { + const store = new ConnectionStore(memoryMemento(), memorySecrets()); + const profile = await store.save( + { name: 'MySQL', engine: 'mysql', host: 'localhost', port: 3306, user: 'u' }, + 'old-password', + ); + await store.save({ ...profile, name: 'MySQL editado' }, undefined, { + keepExistingPassword: true, + }); + await expect(store.getPassword(profile.id)).resolves.toBe('old-password'); + }); + + it('elimina perfil y secreto y evita nombres duplicados', async () => { + const state = memoryMemento(); + const secrets = memorySecrets(); + const store = new ConnectionStore(state, secrets); + const one = await store.save( + { name: 'Oracle', engine: 'oracle', host: 'host', database: 'svc', user: 'u' }, + 'pw', + ); + await expect( + store.save( + { name: 'Oracle', engine: 'sqlite', filePath: '/tmp/other.db' }, + '', + ), + ).rejects.toThrow(/Ya existe/); + await store.delete(one.id); + expect(store.get(one.id)).toBeUndefined(); + await expect(store.getPassword(one.id)).resolves.toBe(''); + }); +}); diff --git a/src/test/databaseEngines.test.js b/src/test/databaseEngines.test.js new file mode 100644 index 0000000..4a1f6a3 --- /dev/null +++ b/src/test/databaseEngines.test.js @@ -0,0 +1,38 @@ +'use strict'; + +const { + DATABASE_ENGINES, + DATABASE_ENGINE_IDS, + getDatabaseEngine, + isDatabaseEngineId, +} = require('../databaseEngines'); + +describe('databaseEngines', () => { + it('expone exactamente los cinco motores soportados', () => { + expect(DATABASE_ENGINE_IDS).toEqual([ + 'sqlite', + 'postgresql', + 'mysql', + 'sqlserver', + 'oracle', + ]); + expect(DATABASE_ENGINES).toHaveLength(5); + }); + + it('define puertos y grupos de objetos propios de cada motor', () => { + expect(getDatabaseEngine('sqlite').defaultPort).toBeNull(); + expect(getDatabaseEngine('postgresql').defaultPort).toBe(5432); + expect(getDatabaseEngine('mysql').defaultPort).toBe(3306); + expect(getDatabaseEngine('sqlserver').defaultPort).toBe(1433); + expect(getDatabaseEngine('oracle').defaultPort).toBe(1521); + expect(getDatabaseEngine('oracle').objectGroups).toContain('packages'); + expect(getDatabaseEngine('postgresql').objectGroups).toContain('materializedViews'); + expect(getDatabaseEngine('mysql').objectGroups).toContain('events'); + }); + + it('valida identificadores de motor sin aceptar valores desconocidos', () => { + expect(isDatabaseEngineId('oracle')).toBe(true); + expect(isDatabaseEngineId('mongo')).toBe(false); + expect(getDatabaseEngine('mongo')).toBeUndefined(); + }); +}); diff --git a/src/test/databaseEngines.test.ts b/src/test/databaseEngines.test.ts deleted file mode 100644 index 7efeb29..0000000 --- a/src/test/databaseEngines.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { DATABASE_ENGINES } from '../databaseEngines'; - -describe('DATABASE_ENGINES', () => { - it('declara los cuatro motores previstos sin identificadores repetidos', () => { - const ids = DATABASE_ENGINES.map((engine) => engine.id); - - expect(ids).toEqual(['postgresql', 'mysql', 'sqlserver', 'oracle']); - expect(new Set(ids).size).toBe(DATABASE_ENGINES.length); - }); - - it('utiliza los puertos predeterminados correctos', () => { - const ports = Object.fromEntries( - DATABASE_ENGINES.map((engine) => [engine.id, engine.defaultPort]), - ); - - expect(ports).toEqual({ - postgresql: 5432, - mysql: 3306, - sqlserver: 1433, - oracle: 1521, - }); - }); -}); diff --git a/src/test/ddlTemplates.test.js b/src/test/ddlTemplates.test.js new file mode 100644 index 0000000..aa1f803 --- /dev/null +++ b/src/test/ddlTemplates.test.js @@ -0,0 +1,106 @@ +'use strict'; + +const { + OBJECT_TYPES_BY_ENGINE, + alterTemplate, + createTemplate, + dropTemplate, + objectTypesForEngine, +} = require('../sql/ddlTemplates'); + +const quote = (value) => `"${value}"`; + +describe('DDL templates', () => { + it('ofrece CREATE/ALTER/DROP para todos los tipos anunciados', () => { + for (const [engine, types] of Object.entries(OBJECT_TYPES_BY_ENGINE)) { + expect(objectTypesForEngine(engine)).toEqual(types); + for (const type of types) { + const created = createTemplate(engine, type, '"schema"."object"', quote, { + identifierName: '"object"', + }); + const altered = alterTemplate(engine, type, '"schema"."object"'); + const dropped = dropTemplate(engine, type, '"schema"."object"'); + expect(created.length).toBeGreaterThan(10); + expect(altered.length).toBeGreaterThan(5); + expect(dropped).toMatch(/^DROP /); + } + } + }); + + it('crea un DROP INDEX correcto para MySQL y SQL Server cuando conoce la tabla', () => { + expect( + dropTemplate('mysql', 'index', '`db`.`idx`', { + identifierName: '`idx`', + tableQualifiedName: '`db`.`items`', + }), + ).toBe('DROP INDEX `idx` ON `db`.`items`;'); + expect( + dropTemplate('sqlserver', 'index', '[dbo].[idx]', { + identifierName: '[idx]', + tableQualifiedName: '[dbo].[items]', + }), + ).toBe('DROP INDEX [idx] ON [dbo].[items];'); + }); + + it('respeta las reglas DDL especiales de índices, triggers y constraints', () => { + expect( + createTemplate('postgresql', 'index', '"public"."idx_items"', quote, { + identifierName: '"idx_items"', + }), + ).toMatch(/^CREATE INDEX "idx_items"/); + expect( + createTemplate('postgresql', 'trigger', '"public"."trg_items"', quote, { + identifierName: '"trg_items"', + }), + ).toContain('CREATE TRIGGER "trg_items"'); + expect( + dropTemplate('postgresql', 'trigger', '"public"."trg_items"', { + identifierName: '"trg_items"', + tableQualifiedName: '"public"."items"', + }), + ).toBe('DROP TRIGGER IF EXISTS "trg_items" ON "public"."items";'); + expect( + dropTemplate('mysql', 'index', '`db`.`PRIMARY`', { + identifierName: '`PRIMARY`', + objectName: 'PRIMARY', + tableQualifiedName: '`db`.`items`', + }), + ).toBe('ALTER TABLE `db`.`items` DROP PRIMARY KEY;'); + expect( + dropTemplate('sqlserver', 'index', '[dbo].[PK_items]', { + identifierName: '[PK_items]', + isConstraint: true, + tableQualifiedName: '[dbo].[items]', + }), + ).toBe('ALTER TABLE [dbo].[items] DROP CONSTRAINT [PK_items];'); + expect( + dropTemplate('postgresql', 'index', '"public"."items_pkey"', { + constraintName: '"items_pkey"', + tableQualifiedName: '"public"."items"', + }), + ).toBe('ALTER TABLE "public"."items" DROP CONSTRAINT IF EXISTS "items_pkey";'); + }); + + it('genera ALTER específico cuando el motor necesita el nombre de tabla', () => { + expect( + alterTemplate('sqlserver', 'index', '[dbo].[idx]', { + identifierName: '[idx]', + tableQualifiedName: '[dbo].[items]', + }), + ).toContain('ALTER INDEX [idx] ON [dbo].[items]'); + expect( + alterTemplate('postgresql', 'trigger', '"public"."trg"', { + identifierName: '"trg"', + tableQualifiedName: '"public"."items"', + }), + ).toContain('ALTER TRIGGER "trg" ON "public"."items"'); + }); + + it('no introduce TOP/LIMIT/FETCH en las plantillas SELECT', () => { + for (const engine of Object.keys(OBJECT_TYPES_BY_ENGINE)) { + if (!objectTypesForEngine(engine).includes('view')) continue; + const sql = createTemplate(engine, 'view', 'demo', quote); + expect(sql).not.toMatch(/\b(?:TOP|LIMIT|FETCH)\b/i); + } + }); +}); diff --git a/src/test/resultStore.test.js b/src/test/resultStore.test.js new file mode 100644 index 0000000..1ff2329 --- /dev/null +++ b/src/test/resultStore.test.js @@ -0,0 +1,60 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { ResultStore } = require('../storage/resultStore'); + +describe('ResultStore', () => { + let temporary; + let store; + + beforeEach(async () => { + temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'simple-db-result-')); + store = new ResultStore(path.join(temporary, 'results'), { + pageSize: 2, + maxCellCharacters: 100, + }); + await store.initialize(); + }); + + afterEach(async () => { + await store?.dispose(); + await fs.rm(temporary, { recursive: true, force: true }); + }); + + it('pagina en disco sin truncar el número de filas', async () => { + await store.createExecution('exec-1', { connectionName: 'test' }); + await store.startSet('exec-1', 0, { + columns: [{ name: 'id', type: 'INTEGER' }], + }); + await store.appendRows('exec-1', 0, [[1], [2], [3], [4], [5]]); + await store.finishSet('exec-1', 0); + const metadata = await store.finalizeExecution('exec-1', { status: 'success' }); + + expect(metadata.sets[0].rowCount).toBe(5); + expect(metadata.sets[0].pages).toBe(3); + expect(await store.getPage('exec-1', 0, 0)).toEqual([[1], [2]]); + expect(await store.getPage('exec-1', 0, 2)).toEqual([[5]]); + + const allRows = []; + for await (const row of store.iterateRows('exec-1', 0)) allRows.push(row); + expect(allRows).toEqual([[1], [2], [3], [4], [5]]); + }); + + it('permite reconfigurar el tamaño de página para ejecuciones nuevas', async () => { + store.configure({ pageSize: 3, maxCellCharacters: 5 }); + await store.createExecution('exec-2', {}); + await store.startSet('exec-2', 0, { columns: [{ name: 'text' }] }); + await store.appendRows('exec-2', 0, [ + ['abcdefghij'], + ['two'], + ['three'], + ['four'], + ]); + await store.finishSet('exec-2', 0); + const metadata = await store.finalizeExecution('exec-2'); + expect(metadata.sets[0].pages).toBe(2); + expect((await store.getPage('exec-2', 0, 0))[0][0]).toContain('omitidos'); + }); +}); diff --git a/src/test/safety.test.js b/src/test/safety.test.js new file mode 100644 index 0000000..0616d4a --- /dev/null +++ b/src/test/safety.test.js @@ -0,0 +1,27 @@ +'use strict'; + +const { classifySqlSafety, looksLikeRowQuery } = require('../sql/safety'); + +describe('SQL safety', () => { + it('detecta DROP/TRUNCATE reales pero no texto dentro de strings/comentarios', () => { + expect(classifySqlSafety('DROP TABLE demo;', 'postgresql').destructive).toBe(true); + expect(classifySqlSafety('TRUNCATE TABLE demo;', 'mysql').destructive).toBe(true); + expect(classifySqlSafety("SELECT 'DROP TABLE demo'", 'postgresql').destructive).toBe(false); + expect(classifySqlSafety('-- DROP TABLE demo\nSELECT 1', 'postgresql').destructive).toBe(false); + expect(classifySqlSafety('# DROP TABLE demo\nSELECT 1', 'mysql').destructive).toBe(false); + expect(classifySqlSafety("SELECT q'[DROP TABLE demo]' FROM dual", 'oracle').destructive).toBe(false); + }); + + it('avisa de UPDATE/DELETE sin WHERE', () => { + expect(classifySqlSafety('UPDATE demo SET active = 0;', 'sqlite').unsafeDml).toBe(true); + expect(classifySqlSafety('DELETE FROM demo;', 'oracle').unsafeDml).toBe(true); + expect(classifySqlSafety('UPDATE demo SET active = 0 WHERE id = 1;', 'sqlite').unsafeDml).toBe(false); + }); + + it('reconoce consultas que producen filas', () => { + for (const sql of ['SELECT 1', 'WITH x AS (SELECT 1) SELECT * FROM x', 'SHOW TABLES', 'PRAGMA table_info(x)']) { + expect(looksLikeRowQuery(sql, 'postgresql')).toBe(true); + } + expect(looksLikeRowQuery('CREATE TABLE x(id INT)', 'postgresql')).toBe(false); + }); +}); diff --git a/src/test/sqlSplitter.test.js b/src/test/sqlSplitter.test.js new file mode 100644 index 0000000..ccaa24c --- /dev/null +++ b/src/test/sqlSplitter.test.js @@ -0,0 +1,124 @@ +'use strict'; + +const { + createCodeMask, + findStatementAtOffset, + splitSqlDocument, +} = require('../sql/sqlSplitter'); +const { wrapMysqlDefinition } = require('../adapters/mysqlAdapter'); + +describe('sqlSplitter', () => { + it('ignora delimitadores dentro de strings y comentarios', () => { + const sql = "SELECT ';' AS value; -- ; oculto\nSELECT 2 /* ; */;"; + const statements = splitSqlDocument(sql, 'sqlite'); + expect(statements).toHaveLength(2); + expect(statements[0].sql).toContain("';'"); + expect(statements[1].sql).toContain('SELECT 2'); + }); + + it('mantiene cuerpos dollar-quoted de PostgreSQL como una sentencia', () => { + const sql = `CREATE OR REPLACE FUNCTION public.demo() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + PERFORM 1; + RAISE NOTICE 'hola;'; +END; +$$; +SELECT 42;`; + const statements = splitSqlDocument(sql, 'postgresql'); + expect(statements).toHaveLength(2); + expect(statements[0].sql).toContain('PERFORM 1;'); + expect(statements[1].sql).toBe('SELECT 42;'); + }); + + it('interpreta GO y GO n en SQL Server sin enviarlos al servidor', () => { + const sql = 'SELECT 1;\nGO 2\nSELECT 2;\nGO\n'; + const statements = splitSqlDocument(sql, 'sqlserver'); + expect(statements.map((entry) => entry.sql)).toEqual([ + 'SELECT 1;', + 'SELECT 1;', + 'SELECT 2;', + ]); + }); + + it('interpreta DELIMITER de MySQL y conserva el cuerpo de la rutina', () => { + const sql = `DELIMITER $$ +CREATE PROCEDURE demo() +BEGIN + SELECT 1; + SELECT 2; +END$$ +DELIMITER ; +SELECT 3;`; + const statements = splitSqlDocument(sql, 'mysql'); + expect(statements).toHaveLength(2); + expect(statements[0].sql).toContain('SELECT 1;'); + expect(statements[0].sql).not.toContain('DELIMITER'); + expect(statements[1].sql).toBe('SELECT 3;'); + }); + + it('reabre SHOW CREATE de rutinas MySQL como un bloque ejecutable único', () => { + const definition = wrapMysqlDefinition( + 'CREATE PROCEDURE `demo`() BEGIN SELECT 1; SELECT 2; END', + ); + const statements = splitSqlDocument(definition, 'mysql'); + expect(statements).toHaveLength(1); + expect(statements[0].sql).toContain('SELECT 1; SELECT 2;'); + expect(statements[0].sql).not.toContain('DELIMITER'); + }); + + it('ignora delimitadores dentro de comentarios # de MySQL', () => { + const statements = splitSqlDocument('# comentario; oculto\nSELECT 1;', 'mysql'); + expect(statements).toHaveLength(1); + expect(statements[0].sql).toContain('SELECT 1;'); + }); + + it('mantiene bloques PL/SQL Oracle hasta la barra de terminación', () => { + const sql = `CREATE OR REPLACE PROCEDURE demo AS +BEGIN + NULL; +END; +/ +SELECT 1 FROM dual;`; + const statements = splitSqlDocument(sql, 'oracle'); + expect(statements).toHaveLength(2); + expect(statements[0].sql).toContain('END;'); + expect(statements[0].sql).not.toMatch(/\n\s*\/$/); + expect(statements[1].sql).toBe('SELECT 1 FROM dual;'); + }); + + it('respeta literales q-quoted de Oracle al dividir SQL normal', () => { + const sql = "INSERT INTO demo(value) VALUES (q'[uno;dos]'); SELECT 2 FROM dual;"; + const statements = splitSqlDocument(sql, 'oracle'); + expect(statements).toHaveLength(2); + expect(statements[0].sql).toContain("q'[uno;dos]'"); + }); + + it('mantiene un CREATE TRIGGER SQLite con sentencias internas', () => { + const sql = `CREATE TRIGGER audit_insert AFTER INSERT ON items +BEGIN + INSERT INTO audit(message) VALUES ('uno;dos'); + UPDATE counters SET value = value + 1; +END; +SELECT * FROM items;`; + const statements = splitSqlDocument(sql, 'sqlite'); + expect(statements).toHaveLength(2); + expect(statements[0].sql).toContain('UPDATE counters'); + expect(statements[1].sql).toBe('SELECT * FROM items;'); + }); + + it('localiza la sentencia bajo el cursor', () => { + const sql = 'SELECT 1;\n\nSELECT 22;\nSELECT 333;'; + const offset = sql.indexOf('22') + 1; + expect(findStatementAtOffset(sql, 'postgresql', offset).sql).toBe('SELECT 22;'); + }); + + it('enmascara literales y comentarios sin cambiar offsets ni saltos de línea', () => { + const sql = "SELECT 'secreto;'; -- comentario\nSELECT 1;"; + const mask = createCodeMask(sql); + expect(mask).toHaveLength(sql.length); + expect(mask.split('\n')).toHaveLength(sql.split('\n').length); + expect(mask).not.toContain('secreto'); + expect(mask).not.toContain('comentario'); + }); +}); diff --git a/src/test/sqliteAdapter.test.js b/src/test/sqliteAdapter.test.js new file mode 100644 index 0000000..e3a60bc --- /dev/null +++ b/src/test/sqliteAdapter.test.js @@ -0,0 +1,180 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { SqliteAdapter } = require('../adapters/sqliteAdapter'); + +function collectingSink() { + const sets = []; + return { + sets, + start: async (index, columns) => { + sets[index] = { columns, rows: [], metadata: null }; + }, + rows: async (index, rows) => sets[index].rows.push(...rows), + end: async (index, metadata) => { + sets[index].metadata = metadata; + }, + }; +} + +describe('SqliteAdapter integration', () => { + let temporary; + let adapter; + + beforeEach(async () => { + temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'simple-db-sqlite-')); + adapter = new SqliteAdapter( + { + id: 'sqlite-test', + engine: 'sqlite', + filePath: path.join(temporary, 'database.sqlite'), + readOnly: false, + queryTimeoutMs: 0, + }, + '', + ); + await adapter.connect(); + }); + + afterEach(async () => { + await adapter?.disconnect().catch(() => {}); + await fs.rm(temporary, { recursive: true, force: true }); + }); + + async function execute(sql, maxRows = 0) { + const sink = collectingSink(); + const result = await adapter.execute('session', sql, { + executionId: `execution-${Date.now()}-${Math.random()}`, + maxRows, + pageSize: 2, + sink, + }); + return { result, sink }; + } + + it('crea, modifica y consulta datos sin límite de filas por defecto', async () => { + await execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL);'); + await execute("INSERT INTO items(name) VALUES ('a'), ('b'), ('c'), ('d'), ('e');"); + const { result, sink } = await execute('SELECT id, name FROM items ORDER BY id;'); + + expect(result.rowCount).toBe(5); + expect(result.truncated).toBe(false); + expect(sink.sets[0].rows).toHaveLength(5); + expect(sink.sets[0].rows[4][1]).toBe('e'); + }); + + it('sólo limita filas cuando maxRows es mayor que cero', async () => { + await execute('CREATE TABLE numbers (value INTEGER);'); + await execute('INSERT INTO numbers VALUES (1),(2),(3),(4);'); + const { result, sink } = await execute('SELECT value FROM numbers ORDER BY value;', 2); + expect(result.rowCount).toBe(2); + expect(result.truncated).toBe(true); + expect(sink.sets[0].rows).toEqual([[1], [2]]); + }); + + it('conserva enteros SQLite de 64 bits como BigInt hasta la normalización', async () => { + await execute('CREATE TABLE big_numbers (value INTEGER);'); + await execute('INSERT INTO big_numbers VALUES (9223372036854775807);'); + const { sink } = await execute('SELECT value FROM big_numbers;'); + expect(sink.sets[0].rows[0][0]).toBe(9223372036854775807n); + }); + + it('hace ROLLBACK y COMMIT de transacciones explícitas', async () => { + await execute('CREATE TABLE tx (value TEXT);'); + await adapter.begin('session'); + await execute("INSERT INTO tx VALUES ('rollback');"); + await adapter.rollback('session'); + expect((await execute('SELECT COUNT(*) AS total FROM tx;')).sink.sets[0].rows[0][0]).toBe(0); + + await adapter.begin('session'); + await execute("INSERT INTO tx VALUES ('commit');"); + await adapter.commit('session'); + expect((await execute('SELECT COUNT(*) AS total FROM tx;')).sink.sets[0].rows[0][0]).toBe(1); + }); + + it('sincroniza BEGIN/ROLLBACK escritos como SQL y aísla otros editores', async () => { + await execute('CREATE TABLE raw_tx (value TEXT);'); + await execute('BEGIN;'); + expect(adapter.hasTransaction('session')).toBe(true); + const otherSink = collectingSink(); + await expect( + adapter.execute('other-session', 'SELECT 1;', { + executionId: 'other-execution', + maxRows: 0, + pageSize: 2, + sink: otherSink, + }), + ).rejects.toThrow(/otro editor/i); + await execute("INSERT INTO raw_tx VALUES ('no guardar');"); + await execute('ROLLBACK;'); + expect(adapter.hasTransaction('session')).toBe(false); + expect((await execute('SELECT COUNT(*) FROM raw_tx;')).sink.sets[0].rows[0][0]).toBe(0); + }); + + it('mantiene foreign_keys activo después de persistir el archivo', async () => { + await execute('CREATE TABLE parent (id INTEGER PRIMARY KEY);'); + await execute('CREATE TABLE child (parent_id INTEGER REFERENCES parent(id));'); + await expect(execute('INSERT INTO child(parent_id) VALUES (999);')).rejects.toThrow( + /FOREIGN KEY/i, + ); + }); + + it('se niega a sobrescribir un archivo modificado externamente', async () => { + await execute('CREATE TABLE conflict (id INTEGER);'); + const filename = path.join(temporary, 'database.sqlite'); + const stat = await fs.stat(filename); + const changed = new Date(stat.mtimeMs + 5000); + await fs.utimes(filename, changed, changed); + await expect(execute('INSERT INTO conflict VALUES (1);')).rejects.toThrow( + /modificado por otra aplicación/i, + ); + }); + + it('obliga a reconectar también antes de leer si el archivo cambió externamente', async () => { + await execute('CREATE TABLE stale_guard (id INTEGER);'); + const filename = path.join(temporary, 'database.sqlite'); + const stat = await fs.stat(filename); + const changed = new Date(stat.mtimeMs + 5000); + await fs.utimes(filename, changed, changed); + await expect(execute('SELECT * FROM stale_guard;')).rejects.toThrow( + /modificado por otra aplicación/i, + ); + }); + + it('rechaza un WAL activo para no cargar una instantánea incompleta', async () => { + await execute('CREATE TABLE wal_guard (id INTEGER);'); + const filename = path.join(temporary, 'database.sqlite'); + await adapter.disconnect(); + await fs.writeFile(`${filename}-wal`, Buffer.alloc(64, 1)); + + adapter = new SqliteAdapter( + { + id: 'sqlite-test-wal', + engine: 'sqlite', + filePath: filename, + readOnly: false, + queryTimeoutMs: 0, + }, + '', + ); + await expect(adapter.connect()).rejects.toThrow(/WAL activo/i); + }); + + it('explora tablas, columnas, índices, triggers y sus definiciones', async () => { + await execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL);'); + await execute('CREATE INDEX idx_items_name ON items(name);'); + await execute(`CREATE TRIGGER trg_items AFTER INSERT ON items +BEGIN + UPDATE items SET name = name WHERE id = NEW.id; +END;`); + + expect((await adapter.listTables('main', 'main')).map((row) => row.name)).toContain('items'); + expect((await adapter.listColumns('main', 'main', 'items')).map((row) => row.name)).toEqual(['id', 'name']); + expect((await adapter.listIndexes('main', 'main')).map((row) => row.name)).toContain('idx_items_name'); + expect((await adapter.listTriggers('main', 'main')).map((row) => row.name)).toContain('trg_items'); + expect(await adapter.getObjectDefinition('main', 'main', 'items', 'table')).toMatch(/^CREATE TABLE/i); + expect(await adapter.getObjectDefinition('main', 'main', 'trg_items', 'trigger')).toMatch(/^CREATE TRIGGER/i); + }); +}); diff --git a/src/test/transactionControl.test.js b/src/test/transactionControl.test.js new file mode 100644 index 0000000..3bb6324 --- /dev/null +++ b/src/test/transactionControl.test.js @@ -0,0 +1,30 @@ +'use strict'; + +const { detectTransactionControl } = require('../sql/transactionControl'); + +describe('transactionControl', () => { + it('detecta controles explícitos habituales de PostgreSQL y MySQL', () => { + expect(detectTransactionControl('BEGIN;', 'postgresql')).toBe('begin'); + expect(detectTransactionControl('START TRANSACTION;', 'mysql')).toBe('begin'); + expect( + detectTransactionControl('BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY;', 'postgresql'), + ).toBe('begin'); + expect( + detectTransactionControl('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ WRITE;', 'mysql'), + ).toBe('begin'); + expect(detectTransactionControl('COMMIT WORK;', 'postgresql')).toBe('commit'); + expect(detectTransactionControl('ROLLBACK;', 'mysql')).toBe('rollback'); + }); + + it('detecta BEGIN TRANSACTION de SQL Server pero no BEGIN TRY', () => { + expect(detectTransactionControl('BEGIN TRANSACTION;', 'sqlserver')).toBe('begin'); + expect(detectTransactionControl('BEGIN TRAN tx_name;', 'sqlserver')).toBe('begin'); + expect(detectTransactionControl('BEGIN TRY', 'sqlserver')).toBeNull(); + }); + + it('no confunde bloques Oracle ni transacciones SQLite gestionadas por su worker', () => { + expect(detectTransactionControl('BEGIN NULL; END;', 'oracle')).toBeNull(); + expect(detectTransactionControl('BEGIN IMMEDIATE;', 'sqlite')).toBeNull(); + expect(detectTransactionControl('ROLLBACK TO SAVEPOINT x;', 'postgresql')).toBeNull(); + }); +}); diff --git a/src/test/valueNormalizer.test.js b/src/test/valueNormalizer.test.js new file mode 100644 index 0000000..e5ad267 --- /dev/null +++ b/src/test/valueNormalizer.test.js @@ -0,0 +1,30 @@ +'use strict'; + +const { normalizeRows, normalizeValue, truncateText } = require('../core/valueNormalizer'); + +describe('valueNormalizer', () => { + it('conserva tipos escalares y normaliza valores especiales', () => { + expect(normalizeValue(null)).toBeNull(); + expect(normalizeValue(42)).toBe(42); + expect(normalizeValue(true)).toBe(true); + expect(normalizeValue(123n)).toBe('123'); + expect(normalizeValue(new Date('2026-01-02T03:04:05.000Z'))).toBe( + '2026-01-02T03:04:05.000Z', + ); + }); + + it('limita celdas grandes de forma explícita y no filas', () => { + expect(truncateText('abcdefghij', 5)).toContain('abcde…'); + const rows = normalizeRows( + [{ value: 'abcdefghij' }, { value: 'klmnopqrst' }], + [{ name: 'value' }], + 5, + ); + expect(rows).toHaveLength(2); + expect(rows[0][0]).toContain('omitidos'); + }); + + it('genera una vista segura para binarios', () => { + expect(normalizeValue(Buffer.from([0, 1, 2]))).toContain(''); + }); +}); diff --git a/src/test/xlsx.test.js b/src/test/xlsx.test.js new file mode 100644 index 0000000..3ca4d8d --- /dev/null +++ b/src/test/xlsx.test.js @@ -0,0 +1,30 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const ExcelJS = require('exceljs'); + +describe('XLSX export dependency', () => { + it('escribe y vuelve a leer un XLSX con el writer de streaming', async () => { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'simple-db-xlsx-')); + const filename = path.join(temporary, 'result.xlsx'); + try { + const output = new ExcelJS.stream.xlsx.WorkbookWriter({ filename }); + const sheet = output.addWorksheet('Resultado'); + sheet.addRow(['id', 'name']).commit(); + sheet.addRow([1, 'demo']).commit(); + sheet.commit(); + await output.commit(); + + const input = new ExcelJS.Workbook(); + await input.xlsx.readFile(filename); + expect(input.getWorksheet('Resultado').getRow(2).values.slice(1)).toEqual([ + 1, + 'demo', + ]); + } finally { + await fs.rm(temporary, { recursive: true, force: true }); + } + }); +}); diff --git a/src/ui/connectionForm.js b/src/ui/connectionForm.js new file mode 100644 index 0000000..47e60ab --- /dev/null +++ b/src/ui/connectionForm.js @@ -0,0 +1,381 @@ +'use strict'; + +const vscode = require('vscode'); +const { DATABASE_ENGINES, getDatabaseEngine } = require('../databaseEngines'); + +async function inputText(options) { + return vscode.window.showInputBox({ + title: options.title, + prompt: options.prompt, + value: options.value ?? '', + password: options.password === true, + ignoreFocusOut: true, + validateInput: options.required + ? (value) => (value.trim() ? undefined : 'Este campo es obligatorio.') + : options.validateInput, + }); +} + +async function inputNumber(options) { + const result = await inputText({ + ...options, + value: String(options.value ?? options.defaultValue ?? ''), + required: true, + validateInput: undefined, + }); + if (result === undefined) { + return undefined; + } + const number = Number(result); + if (!Number.isInteger(number) || number < (options.minimum ?? 0)) { + await vscode.window.showErrorMessage( + `${options.prompt}: introduce un número entero válido.`, + ); + return inputNumber(options); + } + return number; +} + +async function inputBoolean(title, label, value) { + const picked = await vscode.window.showQuickPick( + [ + { label: value ? 'Sí' : 'No', value }, + { label: value ? 'No' : 'Sí', value: !value }, + ], + { + title, + placeHolder: label, + ignoreFocusOut: true, + }, + ); + return picked?.value; +} + +async function chooseSqlitePath(existingProfile) { + if (existingProfile) { + return inputText({ + title: 'Simple DB — Editar SQLite', + prompt: 'Ruta completa del archivo SQLite', + value: existingProfile.filePath, + required: true, + }); + } + + const mode = await vscode.window.showQuickPick( + [ + { label: '$(folder-opened) Abrir archivo existente', value: 'open' }, + { label: '$(new-file) Crear archivo nuevo', value: 'create' }, + { label: '$(edit) Escribir la ruta manualmente', value: 'manual' }, + ], + { + title: 'Simple DB — Archivo SQLite', + placeHolder: 'Selecciona cómo indicar el archivo SQLite', + ignoreFocusOut: true, + }, + ); + if (!mode) { + return undefined; + } + + if (mode.value === 'open') { + const selected = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + title: 'Seleccionar base de datos SQLite', + filters: { + 'SQLite database': ['db', 'sqlite', 'sqlite3'], + 'Todos los archivos': ['*'], + }, + }); + return selected?.[0]?.fsPath; + } + + if (mode.value === 'create') { + const selected = await vscode.window.showSaveDialog({ + title: 'Crear base de datos SQLite', + filters: { 'SQLite database': ['db', 'sqlite', 'sqlite3'] }, + }); + return selected?.fsPath; + } + + return inputText({ + title: 'Simple DB — Archivo SQLite', + prompt: 'Ruta completa del archivo SQLite', + required: true, + }); +} + +async function promptConnection(options = {}) { + const existing = options.existingProfile; + let engineId = existing?.engine || options.engineId; + + if (!engineId) { + const enginePick = await vscode.window.showQuickPick( + DATABASE_ENGINES.map((engine) => ({ + label: `$(database) ${engine.displayName}`, + description: + engine.defaultPort === null ? 'Archivo local' : `Puerto ${engine.defaultPort}`, + value: engine.id, + })), + { + title: 'Simple DB — Nueva conexión', + placeHolder: 'Selecciona el motor de base de datos', + ignoreFocusOut: true, + }, + ); + if (!enginePick) { + return null; + } + engineId = enginePick.value; + } + + const engine = getDatabaseEngine(engineId); + if (!engine) { + throw new Error(`Motor no válido: ${engineId}`); + } + + const name = await inputText({ + title: `Simple DB — ${existing ? 'Editar' : 'Nueva'} conexión ${engine.displayName}`, + prompt: 'Nombre visible de la conexión', + value: existing?.name || '', + required: true, + }); + if (name === undefined) { + return null; + } + + const profile = { + ...existing, + name: name.trim(), + engine: engineId, + }; + + let password; + if (engineId === 'sqlite') { + const sqlitePath = await chooseSqlitePath(existing); + if (sqlitePath === undefined) { + return null; + } + const readOnly = await inputBoolean( + 'Simple DB — SQLite', + '¿Abrir en modo solo lectura?', + existing?.readOnly || false, + ); + if (readOnly === undefined) { + return null; + } + profile.filePath = sqlitePath; + profile.readOnly = readOnly; + } else if (engineId === 'oracle') { + const mode = await vscode.window.showQuickPick( + [ + { + label: 'Host + puerto + servicio/PDB', + value: 'service', + description: 'Formato habitual de Oracle Easy Connect', + }, + { + label: 'Connect string / alias TNS', + value: 'connectString', + description: 'Usar una cadena de conexión directamente', + }, + ], + { + title: 'Simple DB — Oracle', + placeHolder: 'Modo de conexión Oracle', + ignoreFocusOut: true, + }, + ); + if (!mode) { + return null; + } + + if (mode.value === 'service') { + profile.host = await inputText({ + title: 'Simple DB — Oracle', + prompt: 'Servidor / host', + value: existing?.host || 'localhost', + required: true, + }); + if (profile.host === undefined) return null; + profile.port = await inputNumber({ + title: 'Simple DB — Oracle', + prompt: 'Puerto', + value: existing?.port || 1521, + minimum: 1, + }); + if (profile.port === undefined) return null; + profile.serviceName = await inputText({ + title: 'Simple DB — Oracle', + prompt: 'Servicio / PDB', + value: existing?.serviceName || existing?.database || '', + required: true, + }); + if (profile.serviceName === undefined) return null; + profile.database = profile.serviceName; + profile.connectString = ''; + } else { + profile.connectString = await inputText({ + title: 'Simple DB — Oracle', + prompt: 'Connect string o alias TNS', + value: existing?.connectString || '', + required: true, + }); + if (profile.connectString === undefined) return null; + profile.host = existing?.host || ''; + profile.port = existing?.port || 1521; + profile.serviceName = existing?.serviceName || ''; + profile.database = existing?.database || ''; + } + + profile.user = await inputText({ + title: 'Simple DB — Oracle', + prompt: 'Usuario', + value: existing?.user || '', + required: true, + }); + if (profile.user === undefined) return null; + password = await inputText({ + title: 'Simple DB — Oracle', + prompt: existing ? 'Contraseña (vacío = conservar la actual)' : 'Contraseña', + password: true, + required: false, + }); + if (password === undefined) return null; + } else { + profile.host = await inputText({ + title: `Simple DB — ${engine.displayName}`, + prompt: 'Servidor / host', + value: existing?.host || 'localhost', + required: true, + }); + if (profile.host === undefined) return null; + + if (engineId === 'sqlserver') { + profile.instanceName = await inputText({ + title: 'Simple DB — SQL Server', + prompt: 'Instancia (opcional; vacío = usar puerto TCP)', + value: existing?.instanceName || '', + }); + if (profile.instanceName === undefined) return null; + } + + profile.port = await inputNumber({ + title: `Simple DB — ${engine.displayName}`, + prompt: 'Puerto', + value: existing?.port || engine.defaultPort, + minimum: 1, + }); + if (profile.port === undefined) return null; + + profile.database = await inputText({ + title: `Simple DB — ${engine.displayName}`, + prompt: engineId === 'mysql' ? 'Base de datos inicial (opcional)' : 'Base de datos inicial', + value: + existing?.database || + (engineId === 'postgresql' ? 'postgres' : engineId === 'sqlserver' ? 'master' : ''), + required: engineId !== 'mysql', + }); + if (profile.database === undefined) return null; + + profile.user = await inputText({ + title: `Simple DB — ${engine.displayName}`, + prompt: 'Usuario', + value: existing?.user || '', + required: true, + }); + if (profile.user === undefined) return null; + + password = await inputText({ + title: `Simple DB — ${engine.displayName}`, + prompt: existing ? 'Contraseña (vacío = conservar la actual)' : 'Contraseña', + password: true, + required: false, + }); + if (password === undefined) return null; + + if (engineId === 'sqlserver') { + profile.encrypt = await inputBoolean( + 'Simple DB — SQL Server', + '¿Cifrar la conexión?', + existing?.encrypt !== false, + ); + if (profile.encrypt === undefined) return null; + profile.trustServerCertificate = await inputBoolean( + 'Simple DB — SQL Server', + '¿Confiar en el certificado del servidor?', + existing?.trustServerCertificate || false, + ); + if (profile.trustServerCertificate === undefined) return null; + } else { + profile.ssl = await inputBoolean( + `Simple DB — ${engine.displayName}`, + '¿Usar SSL/TLS?', + existing?.ssl || false, + ); + if (profile.ssl === undefined) return null; + if (profile.ssl) { + profile.trustServerCertificate = await inputBoolean( + `Simple DB — ${engine.displayName}`, + '¿Aceptar un certificado no verificado?', + existing?.trustServerCertificate || false, + ); + if (profile.trustServerCertificate === undefined) return null; + } else { + profile.trustServerCertificate = false; + } + } + } + + profile.connectTimeoutMs = await inputNumber({ + title: `Simple DB — ${engine.displayName}`, + prompt: 'Tiempo máximo de conexión (ms)', + value: existing?.connectTimeoutMs ?? 15000, + minimum: 1, + }); + if (profile.connectTimeoutMs === undefined) return null; + + profile.queryTimeoutMs = await inputNumber({ + title: `Simple DB — ${engine.displayName}`, + prompt: 'Tiempo máximo por consulta (ms; 0 = sin límite)', + value: existing?.queryTimeoutMs ?? 300000, + minimum: 0, + }); + if (profile.queryTimeoutMs === undefined) return null; + + const finish = await vscode.window.showQuickPick( + [ + { + label: '$(beaker) Probar conexión y guardar', + value: 'test', + description: 'Recomendado', + }, + { + label: '$(save) Guardar sin probar', + value: 'save', + }, + ], + { + title: `Simple DB — ${profile.name}`, + placeHolder: '¿Cómo quieres terminar?', + ignoreFocusOut: true, + }, + ); + if (!finish) { + return null; + } + + const effectivePassword = + existing && password === '' ? options.existingPassword || '' : password || ''; + return { + profile, + password: existing && password === '' ? undefined : password, + effectivePassword, + testBeforeSave: finish.value === 'test', + }; +} + +module.exports = { + promptConnection, +}; diff --git a/src/views/connectionsTreeProvider.js b/src/views/connectionsTreeProvider.js new file mode 100644 index 0000000..cfd9613 --- /dev/null +++ b/src/views/connectionsTreeProvider.js @@ -0,0 +1,350 @@ +'use strict'; + +const vscode = require('vscode'); +const { DATABASE_ENGINES, getDatabaseEngine } = require('../databaseEngines'); + +class ConnectionsTreeProvider { + constructor(connectionStore, connectionManager) { + this.connectionStore = connectionStore; + this.connectionManager = connectionManager; + this.changeEmitter = new vscode.EventEmitter(); + this.onDidChangeTreeData = this.changeEmitter.event; + this.managerListener = () => this.refresh(); + this.connectionManager.on('change', this.managerListener); + } + + refresh() { + this.changeEmitter.fire(); + } + + _message(label, parentId, icon = 'info') { + return { kind: 'message', label, parentId, icon }; + } + + getTreeItem(node) { + switch (node.kind) { + case 'engine': { + const engine = getDatabaseEngine(node.engineId); + const count = this.connectionStore + .list() + .filter((profile) => profile.engine === node.engineId).length; + const item = new vscode.TreeItem( + engine.displayName, + vscode.TreeItemCollapsibleState.Expanded, + ); + item.id = `simpleDb.engine.${node.engineId}`; + item.contextValue = 'simpleDb.engine'; + item.description = `${count} conexión${count === 1 ? '' : 'es'}`; + item.iconPath = new vscode.ThemeIcon('server-environment'); + item.tooltip = engine.defaultPort + ? `${engine.displayName} — puerto predeterminado ${engine.defaultPort}` + : `${engine.displayName} — base de datos en archivo`; + return item; + } + case 'connection': { + const profile = this.connectionStore.get(node.profileId); + if (!profile) { + return new vscode.TreeItem('Conexión eliminada'); + } + const status = this.connectionManager.status(profile.id); + const connected = this.connectionManager.isConnected(profile.id); + const transactionCount = this.connectionManager.transactionCount(profile.id); + const item = new vscode.TreeItem( + profile.name, + connected + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None, + ); + item.id = `simpleDb.connection.${profile.id}`; + item.contextValue = connected + ? transactionCount > 0 + ? 'simpleDb.connection.transaction' + : 'simpleDb.connection.connected' + : 'simpleDb.connection.disconnected'; + if (status.state === 'connecting') { + item.description = 'conectando…'; + item.iconPath = new vscode.ThemeIcon('sync~spin'); + } else if (status.state === 'error') { + item.description = 'error'; + item.iconPath = new vscode.ThemeIcon('error'); + } else if (connected) { + item.description = transactionCount > 0 ? `TX: ${transactionCount}` : 'conectada'; + item.iconPath = new vscode.ThemeIcon('database'); + } else { + item.description = 'desconectada'; + item.iconPath = new vscode.ThemeIcon('circle-outline'); + } + let location; + if (profile.engine === 'sqlite') { + location = profile.filePath; + } else if (profile.engine === 'oracle' && profile.connectString) { + location = profile.connectString; + } else if (profile.engine === 'sqlserver' && profile.instanceName) { + location = `${profile.host}\\${profile.instanceName}/${profile.database || ''}`; + } else { + location = `${profile.host}:${profile.port || ''}/${profile.database || profile.serviceName || ''}`; + } + const tooltip = new vscode.MarkdownString(); + tooltip.appendMarkdown(`**${profile.name}** \n`); + tooltip.appendMarkdown(`${getDatabaseEngine(profile.engine)?.displayName || profile.engine} \n`); + tooltip.appendText(location); + if (status.serverVersion) { + tooltip.appendMarkdown(` \nServidor: ${status.serverVersion}`); + } + if (status.error) { + tooltip.appendMarkdown(` \nError: ${status.error}`); + } + item.tooltip = tooltip; + return item; + } + case 'database': { + const item = new vscode.TreeItem( + node.database, + vscode.TreeItemCollapsibleState.Collapsed, + ); + item.id = `simpleDb.database.${node.profileId}.${node.database}`; + item.contextValue = 'simpleDb.database'; + item.iconPath = new vscode.ThemeIcon('database'); + if (node.file) { + item.tooltip = node.file; + } + return item; + } + case 'schema': { + const item = new vscode.TreeItem( + node.schema, + vscode.TreeItemCollapsibleState.Collapsed, + ); + item.contextValue = 'simpleDb.schema'; + item.iconPath = new vscode.ThemeIcon('symbol-namespace'); + return item; + } + case 'group': { + const labels = { + tables: 'Tablas', + views: 'Vistas', + materializedViews: 'Vistas materializadas', + procedures: 'Procedimientos y funciones', + packages: 'Packages', + indexes: 'Índices', + triggers: 'Triggers', + sequences: 'Secuencias', + types: 'Tipos', + synonyms: 'Sinónimos', + events: 'Eventos', + }; + const icons = { + tables: 'table', + views: 'preview', + materializedViews: 'preview', + procedures: 'symbol-method', + packages: 'package', + indexes: 'list-tree', + triggers: 'zap', + sequences: 'list-ordered', + types: 'symbol-class', + synonyms: 'references', + events: 'calendar', + }; + const item = new vscode.TreeItem( + labels[node.groupType] || node.groupType, + vscode.TreeItemCollapsibleState.Collapsed, + ); + item.contextValue = `simpleDb.group.${node.groupType}`; + item.iconPath = new vscode.ThemeIcon(icons[node.groupType] || 'symbol-object'); + return item; + } + case 'object': { + const hasColumns = ['table', 'view', 'materializedView'].includes( + node.objectType, + ); + const item = new vscode.TreeItem( + node.name, + hasColumns + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None, + ); + item.contextValue = `simpleDb.${node.objectType}`; + const icons = { + table: 'table', + view: 'preview', + materializedView: 'preview', + procedure: 'symbol-method', + package: 'package', + index: 'list-tree', + trigger: 'zap', + sequence: 'list-ordered', + type: 'symbol-class', + synonym: 'references', + event: 'calendar', + }; + item.iconPath = new vscode.ThemeIcon(icons[node.objectType] || 'symbol-object'); + item.description = node.type || node.tableName || node.target || ''; + return item; + } + case 'column': { + const item = new vscode.TreeItem( + node.name, + vscode.TreeItemCollapsibleState.None, + ); + item.contextValue = 'simpleDb.column'; + item.iconPath = new vscode.ThemeIcon('symbol-field'); + item.description = `${node.type || ''}${node.nullable === false ? ' • NOT NULL' : ''}`; + return item; + } + case 'message': + default: { + const item = new vscode.TreeItem( + node.label || 'Sin elementos', + vscode.TreeItemCollapsibleState.None, + ); + item.contextValue = 'simpleDb.message'; + item.iconPath = new vscode.ThemeIcon(node.icon || 'info'); + return item; + } + } + } + + async getChildren(node) { + if (!node) { + return DATABASE_ENGINES.map((engine) => ({ + kind: 'engine', + engineId: engine.id, + })); + } + + try { + if (node.kind === 'engine') { + const profiles = this.connectionStore + .list() + .filter((profile) => profile.engine === node.engineId) + .sort((a, b) => a.name.localeCompare(b.name)); + return profiles.length + ? profiles.map((profile) => ({ kind: 'connection', profileId: profile.id })) + : [this._message('Sin conexiones configuradas', node.engineId)]; + } + + if (node.kind === 'connection') { + if (!this.connectionManager.isConnected(node.profileId)) { + return [this._message('Conecta para explorar la base de datos', node.profileId)]; + } + const databases = await this.connectionManager.listDatabases(node.profileId); + return databases.length + ? databases.map((database) => ({ + kind: 'database', + profileId: node.profileId, + database: String(database.name), + file: database.file, + })) + : [this._message('Sin bases de datos visibles', node.profileId)]; + } + + if (node.kind === 'database') { + const profile = this.connectionStore.get(node.profileId); + if (profile.engine === 'mysql' || profile.engine === 'sqlite') { + return this._objectGroups(node.profileId, node.database, node.database); + } + const schemas = await this.connectionManager.listSchemas( + node.profileId, + node.database, + ); + return schemas.length + ? schemas.map((schema) => ({ + kind: 'schema', + profileId: node.profileId, + database: node.database, + schema: String(schema.name), + })) + : [this._message('Sin esquemas visibles', `${node.profileId}.${node.database}`)]; + } + + if (node.kind === 'schema') { + return this._objectGroups(node.profileId, node.database, node.schema); + } + + if (node.kind === 'group') { + const objects = await this.connectionManager.listObjectGroup( + node.profileId, + node.database, + node.schema, + node.groupType, + ); + const objectType = { + tables: 'table', + views: 'view', + materializedViews: 'materializedView', + procedures: 'procedure', + packages: 'package', + indexes: 'index', + triggers: 'trigger', + sequences: 'sequence', + types: 'type', + synonyms: 'synonym', + events: 'event', + }[node.groupType] || 'object'; + return objects.length + ? objects.map((object) => ({ + ...object, + kind: 'object', + objectType, + profileId: node.profileId, + database: node.database, + schema: node.schema, + name: String(object.name), + type: object.type || '', + })) + : [this._message('Sin elementos', `${node.profileId}.${node.groupType}`)]; + } + + if ( + node.kind === 'object' && + ['table', 'view', 'materializedView'].includes(node.objectType) + ) { + const columns = await this.connectionManager.listColumns( + node.profileId, + node.database, + node.schema, + node.name, + ); + return columns.length + ? columns.map((column) => ({ + kind: 'column', + name: String(column.name), + type: column.type || '', + nullable: column.nullable === true || column.nullable === 1, + position: column.position, + })) + : [this._message('Sin columnas visibles', `${node.profileId}.${node.name}`)]; + } + + return []; + } catch (error) { + return [this._message(`Error: ${error.message}`, `error.${Date.now()}`, 'error')]; + } + } + + _objectGroups(profileId, database, schema) { + const engineId = this.connectionStore.get(profileId)?.engine; + const groups = getDatabaseEngine(engineId)?.objectGroups || ['tables', 'views']; + return groups.map((groupType) => ({ + kind: 'group', + groupType, + profileId, + database, + schema, + })); + } + + getParent() { + return undefined; + } + + dispose() { + this.connectionManager.off('change', this.managerListener); + this.changeEmitter.dispose(); + } +} + +module.exports = { + ConnectionsTreeProvider, +}; diff --git a/src/views/connectionsTreeProvider.ts b/src/views/connectionsTreeProvider.ts deleted file mode 100644 index 52d8d57..0000000 --- a/src/views/connectionsTreeProvider.ts +++ /dev/null @@ -1,87 +0,0 @@ -import * as vscode from 'vscode'; - -import { - DATABASE_ENGINES, - type DatabaseEngineDefinition, - type DatabaseEngineId, -} from '../databaseEngines'; - -interface EngineNode { - readonly kind: 'engine'; - readonly engine: DatabaseEngineDefinition; -} - -interface EmptyNode { - readonly kind: 'empty'; - readonly engineId: DatabaseEngineId; -} - -type ConnectionTreeNode = EngineNode | EmptyNode; - -export class ConnectionsTreeProvider - implements vscode.TreeDataProvider, vscode.Disposable -{ - private readonly changeEmitter = - new vscode.EventEmitter(); - - public readonly onDidChangeTreeData = this.changeEmitter.event; - - public refresh(): void { - this.changeEmitter.fire(); - } - - public getTreeItem(element: ConnectionTreeNode): vscode.TreeItem { - if (element.kind === 'engine') { - const item = new vscode.TreeItem( - element.engine.displayName, - vscode.TreeItemCollapsibleState.Collapsed, - ); - - item.id = `simpleDb.engine.${element.engine.id}`; - item.contextValue = 'simpleDb.engine'; - item.description = '0 conexiones'; - item.iconPath = new vscode.ThemeIcon('database'); - item.tooltip = new vscode.MarkdownString( - `**${element.engine.displayName}** \nPuerto predeterminado: ${element.engine.defaultPort}`, - ); - - return item; - } - - const item = new vscode.TreeItem( - 'Sin conexiones configuradas', - vscode.TreeItemCollapsibleState.None, - ); - - item.id = `simpleDb.empty.${element.engineId}`; - item.contextValue = 'simpleDb.empty'; - item.description = 'se añadirá en el Paso 3'; - item.iconPath = new vscode.ThemeIcon('info'); - - return item; - } - - public getChildren(element?: ConnectionTreeNode): ConnectionTreeNode[] { - if (element === undefined) { - return DATABASE_ENGINES.map((engine) => ({ - kind: 'engine', - engine, - })); - } - - if (element.kind === 'engine') { - return [ - { - kind: 'empty', - engineId: element.engine.id, - }, - ]; - } - - return []; - } - - public dispose(): void { - this.changeEmitter.dispose(); - } -} diff --git a/src/views/historyTreeProvider.js b/src/views/historyTreeProvider.js new file mode 100644 index 0000000..383073f --- /dev/null +++ b/src/views/historyTreeProvider.js @@ -0,0 +1,78 @@ +'use strict'; + +const vscode = require('vscode'); +const { getDatabaseEngine } = require('../databaseEngines'); + +function compactSql(sql) { + return String(sql || '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 90); +} + +class HistoryTreeProvider { + constructor(historyStore) { + this.historyStore = historyStore; + this.changeEmitter = new vscode.EventEmitter(); + this.onDidChangeTreeData = this.changeEmitter.event; + this.historyDisposable = historyStore.onDidChange(() => this.refresh()); + } + + refresh() { + this.changeEmitter.fire(); + } + + getTreeItem(node) { + if (node.kind === 'empty') { + const item = new vscode.TreeItem('Sin consultas en el historial'); + item.iconPath = new vscode.ThemeIcon('info'); + return item; + } + + const entry = node.entry; + const item = new vscode.TreeItem( + compactSql(entry.sql) || '(consulta vacía)', + vscode.TreeItemCollapsibleState.None, + ); + item.contextValue = 'simpleDb.historyEntry'; + const affected = entry.affectedRows ? ` • ${entry.affectedRows} afectadas` : ''; + item.description = `${entry.connectionName} • ${entry.rows} filas${affected} • ${entry.durationMs} ms`; + item.iconPath = new vscode.ThemeIcon(entry.success ? 'pass-filled' : 'error'); + item.command = { + command: 'simpleDb.openHistoryEntry', + title: 'Abrir consulta', + arguments: [node], + }; + + const tooltip = new vscode.MarkdownString(); + tooltip.appendMarkdown( + `**${getDatabaseEngine(entry.engine)?.displayName || entry.engine} — ${entry.connectionName}** \n`, + ); + tooltip.appendMarkdown(`${new Date(entry.timestamp).toLocaleString()} \n`); + tooltip.appendCodeblock(entry.sql, 'sql'); + if (entry.error) { + tooltip.appendMarkdown(`\nError: ${entry.error}`); + } + item.tooltip = tooltip; + return item; + } + + getChildren(node) { + if (node) { + return []; + } + const entries = this.historyStore.list(); + return entries.length + ? entries.map((entry) => ({ kind: 'history', entry })) + : [{ kind: 'empty' }]; + } + + dispose() { + this.historyDisposable.dispose(); + this.changeEmitter.dispose(); + } +} + +module.exports = { + HistoryTreeProvider, +}; diff --git a/src/views/resultPanel.js b/src/views/resultPanel.js new file mode 100644 index 0000000..19f63b3 --- /dev/null +++ b/src/views/resultPanel.js @@ -0,0 +1,269 @@ +'use strict'; + +const { randomBytes } = require('node:crypto'); +const vscode = require('vscode'); + +function nonce() { + return randomBytes(18).toString('base64'); +} + +class ResultPanel { + constructor(resultStore, exportService) { + this.resultStore = resultStore; + this.exportService = exportService; + this.panel = null; + this.executionId = null; + this.messageDisposable = null; + this.panelDisposable = null; + } + + async show(executionId) { + const previousExecutionId = this.executionId; + this.executionId = executionId; + + if (!this.panel) { + this.panel = vscode.window.createWebviewPanel( + 'simpleDb.results', + 'Simple DB — Resultados', + vscode.ViewColumn.Beside, + { enableScripts: true, retainContextWhenHidden: true }, + ); + this.messageDisposable = this.panel.webview.onDidReceiveMessage((message) => + this._handleMessage(message), + ); + this.panelDisposable = this.panel.onDidDispose(() => { + this.messageDisposable?.dispose(); + this.messageDisposable = null; + this.panelDisposable = null; + this.panel = null; + }); + } else { + this.panel.reveal(vscode.ViewColumn.Beside, true); + } + + this.panel.webview.html = this._html(this.panel.webview, executionId); + if (previousExecutionId && previousExecutionId !== executionId) { + await this.resultStore.deleteExecution(previousExecutionId).catch(() => {}); + } + } + + async _handleMessage(message) { + if (!this.panel || !this.executionId) return; + try { + if (message.type === 'ready') { + await this._sendMetadata(); + return; + } + if (message.type === 'page') { + await this._sendPage(Number(message.setIndex), Number(message.pageIndex)); + return; + } + if (message.type === 'export') { + const setIndex = Number(message.setIndex); + const format = String(message.format || '').toLowerCase(); + const filename = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Simple DB — Exportando ${format.toUpperCase()}`, + cancellable: false, + }, + () => + this.exportService.chooseAndExport( + this.executionId, + setIndex, + format, + ), + ); + if (filename) { + vscode.window.showInformationMessage(`Simple DB: exportado a ${filename}`); + } + return; + } + if (message.type === 'copy') { + await vscode.env.clipboard.writeText(String(message.text || '')); + } + } catch (error) { + vscode.window.showErrorMessage(`Simple DB: ${error.message}`); + } + } + + async _sendMetadata() { + const metadata = this.resultStore.getMetadata(this.executionId); + if (!metadata || !this.panel) return; + await this.panel.webview.postMessage({ type: 'metadata', metadata }); + } + + async _sendPage(setIndex, requestedPage) { + const metadata = this.resultStore.getMetadata(this.executionId); + const set = metadata?.sets?.[setIndex]; + if (!set || !this.panel) return; + const maximum = Math.max(0, set.pages - 1); + const pageIndex = Math.min(maximum, Math.max(0, requestedPage || 0)); + const rows = await this.resultStore.getPage(this.executionId, setIndex, pageIndex); + await this.panel.webview.postMessage({ + type: 'pageData', + setIndex, + pageIndex, + rows, + }); + } + + _html(webview, executionId) { + const n = nonce(); + return ` + + + + + + + + +
Simple DBCargando…
+
+ +
Cargando resultados…
+ + +`; + } + + dispose() { + this.messageDisposable?.dispose(); + this.panelDisposable?.dispose(); + this.panel?.dispose(); + this.messageDisposable = null; + this.panelDisposable = null; + this.panel = null; + } +} + +module.exports = { + ResultPanel, +}; diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index bef8a9e..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", - "lib": [ - "ES2022" - ], - "rootDir": "src", - "outDir": "dist", - "sourceMap": true, - "strict": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true - }, - "include": [ - "src/**/*.ts" - ] -}