diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c95962f..9fb2074 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -66,12 +66,6 @@ jobs:
with:
tagName: v__VERSION__
releaseName: "RelWave v__VERSION__"
- # Let GitHub auto-generate the changelog from merged PRs and commits
- # since the last release tag. The generated markdown is injected into
- # latest.json as the `notes` field, which the in-app updater reads and
- # displays in the What's New dialog after update.
- releaseBody: ""
- generateReleaseNotes: true
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 93f291d..579ca0d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -17,10 +17,27 @@ pnpm --dir bridge install
pnpm tauri dev
```
+To build the bridge package for your platform, run one of these from the repo root:
+
+```bash
+cd bridge
+pnpm build:pkg:win
+```
+
+or on Linux:
+
+```bash
+cd bridge
+pnpm build:pkg:linux
+```
+
If you are working on bridge-related code, you can run bridge tests directly:
```bash
cd bridge
+
+docker compose -f docker-compose.test.yml up -d
+
pnpm test
```
diff --git a/FEATURES.md b/FEATURES.md
index af6e460..2df8aaf 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -50,9 +50,12 @@ The main landing page for managing database connections. Features a clean, IDE-i
**Connection Management**
- Add new database connections with detailed configuration (name, type, host, port, user, password, SSL options)
+- **SSH Tunneling** — connect securely to remote databases via SSH tunnels using private keys and passphrases
- **SQLite support** — connect to local `.db`, `.sqlite`, `.sqlite3`, `.s3db` files via native file picker
- Connect via URL — paste connection strings like `postgres://user:pass@host:port/db`
- Auto-parse URLs to populate connection form fields (including `sqlite://` protocol)
+- **Safe connection deletion** — intercepts deletion to prevent accidental loss of associated project data (schemas, queries, diagrams)
+- **Unlinked project management** — view, delete, or relink orphaned projects to new connections directly from the sidebar
- Delete existing database connections
- Test connections with real-time feedback
- Connection status indicators for all databases
@@ -376,6 +379,30 @@ RelWave includes native Git integration powered by `simple-git`, providing a ful
---
+## AI Features and Providers
+
+RelWave integrates deeply with multiple Large Language Models (LLMs) to provide intelligent database assistance, schema analysis, and query explanations.
+
+### Supported Providers
+The application supports a flexible, multi-provider AI architecture:
+- **OpenAI** (GPT-4o, GPT-4 Turbo, etc.)
+- **Anthropic** (Claude 3.5 Sonnet, Opus, etc.)
+- **Mistral** (Mistral Large, Mistral Small, etc.)
+- **Groq** (Llama 3, Mixtral, etc. for ultra-fast inference)
+- **Ollama** (Local models like Llama 3, Phi-3, Mistral)
+- **Google Gemini** (Gemini 1.5 Pro, Flash)
+
+### AI Capabilities
+| Feature | Description |
+| ------- | ----------- |
+| **Schema Analysis** | Analyzes the structure of your database, identifying relationships, potential optimizations, and providing a human-readable summary of complex schemas. Uses a highly optimized, token-efficient dense schema representation. |
+| **Query Explanation** | Breaks down complex SQL queries into plain English, explaining joins, filters, performance implications, and the overall intent of the query. |
+| **Local AI Support** | Full privacy and zero-cost inference available through local Ollama integration, ensuring sensitive database schemas never leave your machine. |
+| **Context Aware** | The AI system automatically receives the dialect (PostgreSQL, MySQL, SQLite) and the relevant database schema context to provide accurate, dialect-specific responses. |
+| **High Token Capacity** | Configured with large output windows (up to 4096 tokens) to handle extensive schema analyses and complex query breakdowns without truncation. |
+
+---
+
## Visual Tools
### Chart Visualization
@@ -642,6 +669,6 @@ All database and Git operations use a JSON-RPC protocol over stdin/stdout. The b
---
-**Last Updated:** May 2026
+**Last Updated:** June 2026
This document is maintained alongside the application and updated with each release.
diff --git a/README.md b/README.md
index 02ce0e6..141b213 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
_A high-performance, cross-platform desktop suite for developers who demand more from their database tools._
-[](https://github.com/Relwave/relwave-app/releases)
+[](https://github.com/Relwave/relwave-app/releases)
[](LICENSE)
[](https://github.com/Relwave/relwave-app/releases)
[](https://tauri.app/)
diff --git a/bridge/__tests__/projectStore.test.ts b/bridge/__tests__/projectStore.test.ts
index a3a5f2d..8acecda 100644
--- a/bridge/__tests__/projectStore.test.ts
+++ b/bridge/__tests__/projectStore.test.ts
@@ -257,9 +257,12 @@ describe("ProjectStore", () => {
name: "users",
type: "BASE TABLE",
columns: [
- { name: "id", type: "integer", nullable: false, isPrimaryKey: true, isForeignKey: false, defaultValue: null, isUnique: true },
- { name: "email", type: "varchar(255)", nullable: false, isPrimaryKey: false, isForeignKey: false, defaultValue: null, isUnique: true },
+ { name: "id", type: "integer", nullable: false, isPrimaryKey: true, isForeignKey: false, defaultValue: null, isUnique: true, isSerial: true, ordinalPosition: 1 },
+ { name: "email", type: "varchar(255)", nullable: false, isPrimaryKey: false, isForeignKey: false, defaultValue: null, isUnique: true, isSerial: false, ordinalPosition: 2 },
],
+ indexes: [],
+ foreignKeys: [],
+ checks: [],
},
],
},
@@ -301,8 +304,11 @@ describe("ProjectStore", () => {
name: "posts",
type: "BASE TABLE",
columns: [
- { name: "id", type: "integer", nullable: false, isPrimaryKey: true, isForeignKey: false, defaultValue: null, isUnique: true },
+ { name: "id", type: "integer", nullable: false, isPrimaryKey: true, isForeignKey: false, defaultValue: null, isUnique: true, isSerial: true, ordinalPosition: 1 },
],
+ indexes: [],
+ foreignKeys: [],
+ checks: [],
},
],
},
diff --git a/bridge/package.json b/bridge/package.json
index 1fb23b9..d4f078a 100644
--- a/bridge/package.json
+++ b/bridge/package.json
@@ -1,32 +1,39 @@
{
"name": "relwave-bridge",
- "version": "0.7.0-beta-1",
+ "version": " 0.9.0-rc-1",
"type": "commonjs",
"main": "dist/index.cjs",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"start": "node dist/index.cjs",
"build": "tsc --project tsconfig.json && esbuild src/index.ts --bundle --platform=node --outfile=dist/index.cjs --format=cjs --packages=external",
+ "build:bundle-for-pkg": "esbuild src/index.ts --bundle --platform=node --outfile=dist/index.bundled.cjs --format=cjs --external:better-sqlite3 --external:@napi-rs/keyring --external:ssh2 --external:cpu-features --external:pg-native",
"copy:native": "node scripts/copy-native.js",
"rebuild:native": "npm rebuild better-sqlite3",
- "build:pkg:win": "npm run build && npm run rebuild:native && npm run copy:native && npx @yao-pkg/pkg . --target node22-win-x64 --output ../src-tauri/resources/bridge-x86_64-pc-windows-msvc.exe",
- "build:pkg:linux": "npm run build && npm run rebuild:native && npm run copy:native && npx @yao-pkg/pkg . --target node22-linux-x64 --output ../src-tauri/resources/bridge-x86_64-unknown-linux-gnu",
+ "build:pkg:win": "npm run build && npm run build:bundle-for-pkg && npm run rebuild:native && npm run copy:native && npx @yao-pkg/pkg dist/index.bundled.cjs --target node22-win-x64 --output ../src-tauri/resources/bridge-x86_64-pc-windows-msvc.exe",
+ "build:pkg:linux": "npm run build && npm run build:bundle-for-pkg && npm run rebuild:native && npm run copy:native && npx @yao-pkg/pkg dist/index.bundled.cjs --target node22-linux-x64 --output ../src-tauri/resources/bridge-x86_64-unknown-linux-gnu",
"test": "jest",
"test:watch": "jest --watchAll --detectOpenHandles"
},
"dependencies": {
+ "@anthropic-ai/sdk": "^0.100.1",
+ "@google/generative-ai": "^0.24.1",
"@jest/globals": "^30.2.0",
+ "@mistralai/mistralai": "^2.2.5",
"@napi-rs/keyring": "^1.2.0",
"@types/ssh2": "^1.15.5",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.9.0",
"dotenv": "^17.2.3",
+ "groq-sdk": "^1.2.1",
"mysql2": "^3.15.3",
+ "ollama": "^0.6.3",
+ "openai": "^6.41.0",
"pg": "^8.16.3",
"pg-query-stream": "^4.10.3",
"pino": "^9.14.0",
"ssh2": "^1.17.0",
- "uuid": "^8.3.2",
+ "uuid": "^9.0.1",
"ws": "^8.19.0"
},
"bin": "./dist/index.cjs",
@@ -53,4 +60,4 @@
"ts-node-dev": "^2.0.0",
"typescript": "^5.0.0"
}
-}
+}
\ No newline at end of file
diff --git a/bridge/pnpm-lock.yaml b/bridge/pnpm-lock.yaml
index 9f5d41d..2b63130 100644
--- a/bridge/pnpm-lock.yaml
+++ b/bridge/pnpm-lock.yaml
@@ -8,9 +8,18 @@ importers:
.:
dependencies:
+ '@anthropic-ai/sdk':
+ specifier: ^0.100.1
+ version: 0.100.1(zod@4.4.3)
+ '@google/generative-ai':
+ specifier: ^0.24.1
+ version: 0.24.1
'@jest/globals':
specifier: ^30.2.0
version: 30.2.0
+ '@mistralai/mistralai':
+ specifier: ^2.2.5
+ version: 2.2.5
'@napi-rs/keyring':
specifier: ^1.2.0
version: 1.2.0
@@ -22,19 +31,28 @@ importers:
version: 3.0.3
better-sqlite3:
specifier: ^11.9.0
- version: 11.9.0
+ version: 11.10.0
dotenv:
specifier: ^17.2.3
- version: 17.2.3
+ version: 17.4.2
+ groq-sdk:
+ specifier: ^1.2.1
+ version: 1.2.1
mysql2:
specifier: ^3.15.3
version: 3.15.3
+ ollama:
+ specifier: ^0.6.3
+ version: 0.6.3
+ openai:
+ specifier: ^6.41.0
+ version: 6.41.0(ws@8.20.1)(zod@4.4.3)
pg:
specifier: ^8.16.3
- version: 8.16.3
+ version: 8.21.0
pg-query-stream:
specifier: ^4.10.3
- version: 4.10.3(pg@8.16.3)
+ version: 4.10.3(pg@8.21.0)
pino:
specifier: ^9.14.0
version: 9.14.0
@@ -42,11 +60,11 @@ importers:
specifier: ^1.17.0
version: 1.17.0
uuid:
- specifier: ^8.3.2
- version: 8.3.2
+ specifier: ^9.0.1
+ version: 9.0.1
ws:
specifier: ^8.19.0
- version: 8.19.0
+ version: 8.20.1
devDependencies:
'@types/better-sqlite3':
specifier: ^7.6.13
@@ -56,70 +74,79 @@ importers:
version: 30.0.0
'@types/node':
specifier: ^20.0.0
- version: 20.19.25
+ version: 20.19.41
'@types/pg':
specifier: ^8.15.6
- version: 8.15.6
+ version: 8.20.0
'@yao-pkg/pkg':
specifier: ^5.12.0
version: 5.16.1
esbuild:
specifier: ^0.27.0
- version: 0.27.0
+ version: 0.27.7
jest:
specifier: ^30.2.0
- version: 30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
+ version: 30.2.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
ts-jest:
specifier: ^29.4.6
- version: 29.4.6(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(esbuild@0.27.7)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)))(typescript@5.9.3)
ts-node:
specifier: ^10.9.2
- version: 10.9.2(@types/node@20.19.25)(typescript@5.9.3)
+ version: 10.9.2(@types/node@20.19.41)(typescript@5.9.3)
ts-node-dev:
specifier: ^2.0.0
- version: 2.0.0(@types/node@20.19.25)(typescript@5.9.3)
+ version: 2.0.0(@types/node@20.19.41)(typescript@5.9.3)
typescript:
specifier: ^5.0.0
version: 5.9.3
packages:
- '@babel/code-frame@7.27.1':
- resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
+ '@anthropic-ai/sdk@0.100.1':
+ resolution: {integrity: sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==}
+ hasBin: true
+ peerDependencies:
+ zod: ^3.25.0 || ^4.0.0
+ peerDependenciesMeta:
+ zod:
+ optional: true
+
+ '@babel/code-frame@7.29.0':
+ resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
- '@babel/compat-data@7.28.5':
- resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==}
+ '@babel/compat-data@7.29.0':
+ resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==}
engines: {node: '>=6.9.0'}
- '@babel/core@7.28.5':
- resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==}
+ '@babel/core@7.29.0':
+ resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==}
engines: {node: '>=6.9.0'}
- '@babel/generator@7.28.5':
- resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==}
+ '@babel/generator@7.29.1':
+ resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
engines: {node: '>=6.9.0'}
- '@babel/helper-compilation-targets@7.27.2':
- resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==}
+ '@babel/helper-compilation-targets@7.28.6':
+ resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
engines: {node: '>=6.9.0'}
'@babel/helper-globals@7.28.0':
resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
engines: {node: '>=6.9.0'}
- '@babel/helper-module-imports@7.27.1':
- resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==}
+ '@babel/helper-module-imports@7.28.6':
+ resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
engines: {node: '>=6.9.0'}
- '@babel/helper-module-transforms@7.28.3':
- resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==}
+ '@babel/helper-module-transforms@7.28.6':
+ resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
- '@babel/helper-plugin-utils@7.27.1':
- resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==}
+ '@babel/helper-plugin-utils@7.28.6':
+ resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1':
@@ -134,12 +161,12 @@ packages:
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
engines: {node: '>=6.9.0'}
- '@babel/helpers@7.28.4':
- resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==}
+ '@babel/helpers@7.28.6':
+ resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==}
engines: {node: '>=6.9.0'}
- '@babel/parser@7.28.5':
- resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==}
+ '@babel/parser@7.29.0':
+ resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -180,8 +207,8 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
- '@babel/plugin-syntax-jsx@7.27.1':
- resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==}
+ '@babel/plugin-syntax-jsx@7.28.6':
+ resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
@@ -228,22 +255,26 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
- '@babel/plugin-syntax-typescript@7.27.1':
- resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==}
+ '@babel/plugin-syntax-typescript@7.28.6':
+ resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
- '@babel/template@7.27.2':
- resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
+ '@babel/runtime@7.29.2':
+ resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/template@7.28.6':
+ resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
- '@babel/traverse@7.28.5':
- resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==}
+ '@babel/traverse@7.29.0':
+ resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
engines: {node: '>=6.9.0'}
- '@babel/types@7.28.5':
- resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
+ '@babel/types@7.29.0':
+ resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@0.2.3':
@@ -253,171 +284,175 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
- '@emnapi/core@1.7.1':
- resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
+ '@emnapi/core@1.10.0':
+ resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
- '@emnapi/runtime@1.7.1':
- resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
+ '@emnapi/runtime@1.10.0':
+ resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
- '@emnapi/wasi-threads@1.1.0':
- resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
+ '@emnapi/wasi-threads@1.2.1':
+ resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
- '@esbuild/aix-ppc64@0.27.0':
- resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==}
+ '@esbuild/aix-ppc64@0.27.7':
+ resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
- '@esbuild/android-arm64@0.27.0':
- resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==}
+ '@esbuild/android-arm64@0.27.7':
+ resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
- '@esbuild/android-arm@0.27.0':
- resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==}
+ '@esbuild/android-arm@0.27.7':
+ resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
- '@esbuild/android-x64@0.27.0':
- resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==}
+ '@esbuild/android-x64@0.27.7':
+ resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
- '@esbuild/darwin-arm64@0.27.0':
- resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==}
+ '@esbuild/darwin-arm64@0.27.7':
+ resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
- '@esbuild/darwin-x64@0.27.0':
- resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==}
+ '@esbuild/darwin-x64@0.27.7':
+ resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
- '@esbuild/freebsd-arm64@0.27.0':
- resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==}
+ '@esbuild/freebsd-arm64@0.27.7':
+ resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
- '@esbuild/freebsd-x64@0.27.0':
- resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==}
+ '@esbuild/freebsd-x64@0.27.7':
+ resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
- '@esbuild/linux-arm64@0.27.0':
- resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==}
+ '@esbuild/linux-arm64@0.27.7':
+ resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
- '@esbuild/linux-arm@0.27.0':
- resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==}
+ '@esbuild/linux-arm@0.27.7':
+ resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
- '@esbuild/linux-ia32@0.27.0':
- resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==}
+ '@esbuild/linux-ia32@0.27.7':
+ resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
- '@esbuild/linux-loong64@0.27.0':
- resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==}
+ '@esbuild/linux-loong64@0.27.7':
+ resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
- '@esbuild/linux-mips64el@0.27.0':
- resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==}
+ '@esbuild/linux-mips64el@0.27.7':
+ resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
- '@esbuild/linux-ppc64@0.27.0':
- resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==}
+ '@esbuild/linux-ppc64@0.27.7':
+ resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
- '@esbuild/linux-riscv64@0.27.0':
- resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==}
+ '@esbuild/linux-riscv64@0.27.7':
+ resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
- '@esbuild/linux-s390x@0.27.0':
- resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==}
+ '@esbuild/linux-s390x@0.27.7':
+ resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
- '@esbuild/linux-x64@0.27.0':
- resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==}
+ '@esbuild/linux-x64@0.27.7':
+ resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
- '@esbuild/netbsd-arm64@0.27.0':
- resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==}
+ '@esbuild/netbsd-arm64@0.27.7':
+ resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
- '@esbuild/netbsd-x64@0.27.0':
- resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==}
+ '@esbuild/netbsd-x64@0.27.7':
+ resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
- '@esbuild/openbsd-arm64@0.27.0':
- resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==}
+ '@esbuild/openbsd-arm64@0.27.7':
+ resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
- '@esbuild/openbsd-x64@0.27.0':
- resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==}
+ '@esbuild/openbsd-x64@0.27.7':
+ resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
- '@esbuild/openharmony-arm64@0.27.0':
- resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==}
+ '@esbuild/openharmony-arm64@0.27.7':
+ resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
- '@esbuild/sunos-x64@0.27.0':
- resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==}
+ '@esbuild/sunos-x64@0.27.7':
+ resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
- '@esbuild/win32-arm64@0.27.0':
- resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==}
+ '@esbuild/win32-arm64@0.27.7':
+ resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
- '@esbuild/win32-ia32@0.27.0':
- resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==}
+ '@esbuild/win32-ia32@0.27.7':
+ resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
- '@esbuild/win32-x64@0.27.0':
- resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==}
+ '@esbuild/win32-x64@0.27.7':
+ resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
+ '@google/generative-ai@0.24.1':
+ resolution: {integrity: sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==}
+ engines: {node: '>=18.0.0'}
+
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -531,6 +566,9 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
+ '@mistralai/mistralai@2.2.5':
+ resolution: {integrity: sha512-ATbWzKkNzNAZ+gtw9MI/c/ULTMG80tKUiRNIbQFfg4OP0uEZZpTfXZeBCNfs5Dq0uqMQ/tQWc4o6RRJQtMrpDA==}
+
'@napi-rs/keyring-darwin-arm64@1.2.0':
resolution: {integrity: sha512-CA83rDeyONDADO25JLZsh3eHY8yTEtm/RS6ecPsY+1v+dSawzT9GywBMu2r6uOp1IEhQs/xAfxgybGAFr17lSA==}
engines: {node: '>= 10'}
@@ -630,6 +668,9 @@ packages:
'@sinonjs/fake-timers@13.0.5':
resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==}
+ '@stablelib/base64@1.0.1':
+ resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
+
'@tsconfig/node10@1.0.12':
resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==}
@@ -675,11 +716,11 @@ packages:
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
- '@types/node@20.19.25':
- resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==}
+ '@types/node@20.19.41':
+ resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
- '@types/pg@8.15.6':
- resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
+ '@types/pg@8.20.0':
+ resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
'@types/ssh2@1.15.5':
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
@@ -809,8 +850,8 @@ packages:
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
engines: {node: '>=0.4.0'}
- acorn@8.15.0:
- resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
+ acorn@8.16.0:
+ resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
hasBin: true
@@ -894,8 +935,9 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
- baseline-browser-mapping@2.9.5:
- resolution: {integrity: sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==}
+ baseline-browser-mapping@2.10.31:
+ resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==}
+ engines: {node: '>=6.0.0'}
hasBin: true
bcrypt-pbkdf@1.0.2:
@@ -905,8 +947,8 @@ packages:
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
hasBin: true
- better-sqlite3@11.9.0:
- resolution: {integrity: sha512-4b9xYnoaskj8eIkke9ZCB42p5bOPabptSku8Rl4Yww70Jf+aHeLvrIjXDJrKQxUEjdppsFb+fdJSjoH4TklROA==}
+ better-sqlite3@11.10.0:
+ resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
@@ -918,18 +960,18 @@ packages:
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
- brace-expansion@1.1.12:
- resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
+ brace-expansion@1.1.14:
+ resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
- brace-expansion@2.0.2:
- resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
+ brace-expansion@2.1.0:
+ resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
- browserslist@4.28.1:
- resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
+ browserslist@4.28.2:
+ resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
@@ -962,8 +1004,8 @@ packages:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
- caniuse-lite@1.0.30001760:
- resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==}
+ caniuse-lite@1.0.30001793:
+ resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
@@ -980,8 +1022,8 @@ packages:
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
- ci-info@4.3.1:
- resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==}
+ ci-info@4.4.0:
+ resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
engines: {node: '>=8'}
cjs-module-lexer@2.1.1:
@@ -1041,8 +1083,8 @@ packages:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
- dedent@1.7.0:
- resolution: {integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==}
+ dedent@1.7.2:
+ resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==}
peerDependencies:
babel-plugin-macros: ^3.1.0
peerDependenciesMeta:
@@ -1069,12 +1111,12 @@ packages:
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
engines: {node: '>=8'}
- diff@4.0.2:
- resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
+ diff@4.0.4:
+ resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==}
engines: {node: '>=0.3.1'}
- dotenv@17.2.3:
- resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
+ dotenv@17.4.2:
+ resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'}
dynamic-dedupe@0.3.0:
@@ -1083,8 +1125,8 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
- electron-to-chromium@1.5.267:
- resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
+ electron-to-chromium@1.5.361:
+ resolution: {integrity: sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==}
emittery@0.13.1:
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
@@ -1102,8 +1144,12 @@ packages:
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
- esbuild@0.27.0:
- resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==}
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ esbuild@0.27.7:
+ resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'}
hasBin: true
@@ -1139,6 +1185,9 @@ packages:
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+ fast-sha256@1.3.0:
+ resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
+
fb-watchman@2.0.2:
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
@@ -1220,6 +1269,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ groq-sdk@1.2.1:
+ resolution: {integrity: sha512-dsDSWJRJf+n2dPiCv7zU3IsJbrh7jfSPqi6vc1q0TTK1oUF6bn+wv4P2VFdynkHpuJ0TTJ57vlpT87judPgVPA==}
+ hasBin: true
+
handlebars@4.7.8:
resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
engines: {node: '>=0.4.7'}
@@ -1244,8 +1297,8 @@ packages:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
- iconv-lite@0.7.0:
- resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==}
+ iconv-lite@0.7.2:
+ resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
ieee754@1.2.1:
@@ -1484,6 +1537,10 @@ packages:
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
+ json-schema-to-ts@3.1.1:
+ resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
+ engines: {node: '>=16'}
+
json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
@@ -1512,12 +1569,8 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
- lru-cache@7.18.3:
- resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
- engines: {node: '>=12'}
-
- lru.min@1.1.3:
- resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==}
+ lru.min@1.1.4:
+ resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
make-dir@4.0.0:
@@ -1545,11 +1598,11 @@ packages:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
- minimatch@3.1.2:
- resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+ minimatch@3.1.5:
+ resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
- minimatch@9.0.5:
- resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
+ minimatch@9.0.9:
+ resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
minimist@1.2.8:
@@ -1577,9 +1630,9 @@ packages:
resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==}
engines: {node: '>= 8.0'}
- named-placeholders@1.1.3:
- resolution: {integrity: sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==}
- engines: {node: '>=12.0.0'}
+ named-placeholders@1.1.6:
+ resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
+ engines: {node: '>=8.0.0'}
nan@2.25.0:
resolution: {integrity: sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==}
@@ -1598,8 +1651,8 @@ packages:
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
- node-abi@3.85.0:
- resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==}
+ node-abi@3.87.0:
+ resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
engines: {node: '>=10'}
node-fetch@2.7.0:
@@ -1614,8 +1667,9 @@ packages:
node-int64@0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
- node-releases@2.0.27:
- resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
+ node-releases@2.0.46:
+ resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==}
+ engines: {node: '>=18'}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
@@ -1625,6 +1679,9 @@ packages:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
+ ollama@0.6.3:
+ resolution: {integrity: sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg==}
+
on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
@@ -1636,6 +1693,17 @@ packages:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
+ openai@6.41.0:
+ resolution: {integrity: sha512-IGWPopZq6Rjoynjfb3NSLf/z2MTw7UiOsm9TAjPGAjUESH7Uq41Trg4QWehBEn58p74i+m7uoRPV2vXcpPXhyA==}
+ peerDependencies:
+ ws: ^8.18.0
+ zod: ^3.25 || ^4.0
+ peerDependenciesMeta:
+ ws:
+ optional: true
+ zod:
+ optional: true
+
p-is-promise@3.0.0:
resolution: {integrity: sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==}
engines: {node: '>=8'}
@@ -1682,11 +1750,11 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
- pg-cloudflare@1.2.7:
- resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==}
+ pg-cloudflare@1.4.0:
+ resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
- pg-connection-string@2.9.1:
- resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==}
+ pg-connection-string@2.13.0:
+ resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==}
pg-cursor@2.15.3:
resolution: {integrity: sha512-eHw63TsiGtFEfAd7tOTZ+TLy+i/2ePKS20H84qCQ+aQ60pve05Okon9tKMC+YN3j6XyeFoHnaim7Lt9WVafQsA==}
@@ -1697,13 +1765,13 @@ packages:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
- pg-pool@3.10.1:
- resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==}
+ pg-pool@3.14.0:
+ resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==}
peerDependencies:
pg: '>=8.0'
- pg-protocol@1.10.3:
- resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==}
+ pg-protocol@1.14.0:
+ resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==}
pg-query-stream@4.10.3:
resolution: {integrity: sha512-h2utrzpOIzeT9JfaqfvBbVuvCfBjH86jNfVrGGTbyepKAIOyTfDew0lAt8bbJjs9n/I5bGDl7S2sx6h5hPyJxw==}
@@ -1714,8 +1782,8 @@ packages:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
- pg@8.16.3:
- resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==}
+ pg@8.21.0:
+ resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
@@ -1729,12 +1797,12 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
- picomatch@2.3.1:
- resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+ picomatch@2.3.2:
+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
- picomatch@4.0.3:
- resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pino-abstract-transport@2.0.0:
@@ -1759,8 +1827,8 @@ packages:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
- postgres-bytea@1.0.0:
- resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==}
+ postgres-bytea@1.0.1:
+ resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
engines: {node: '>=0.10.0'}
postgres-date@1.0.7:
@@ -1791,8 +1859,8 @@ packages:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
- pump@3.0.3:
- resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
+ pump@3.0.4:
+ resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
pure-rand@7.0.1:
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
@@ -1834,8 +1902,8 @@ packages:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
- resolve@1.22.11:
- resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
+ resolve@1.22.12:
+ resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
engines: {node: '>= 0.4'}
hasBin: true
@@ -1861,8 +1929,8 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
- semver@7.7.3:
- resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
+ semver@7.8.1:
+ resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
engines: {node: '>=10'}
hasBin: true
@@ -1926,6 +1994,9 @@ packages:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'}
+ standardwebhooks@1.0.0:
+ resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
+
stream-meter@1.0.4:
resolution: {integrity: sha512-4sOEtrbgFotXwnEuzzsQBYEV1elAeFSO8rSGeTwabuX1RRn/kEq9JVH7I0MRBhKVRR0sJkr0M0QCH7yOLf9fhQ==}
@@ -1951,8 +2022,8 @@ packages:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
- strip-ansi@7.1.2:
- resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
+ strip-ansi@7.2.0:
+ resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}
strip-bom@3.0.0:
@@ -2005,8 +2076,8 @@ packages:
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
- tinyglobby@0.2.15:
- resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
+ tinyglobby@0.2.16:
+ resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
tmpl@1.0.5:
@@ -2023,6 +2094,9 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
+ ts-algebra@2.0.0:
+ resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
+
ts-jest@29.4.6:
resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==}
engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0}
@@ -2118,8 +2192,8 @@ packages:
unrs-resolver@1.11.1:
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
- update-browserslist-db@1.2.2:
- resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==}
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
@@ -2127,8 +2201,9 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
- uuid@8.3.2:
- resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
+ uuid@9.0.1:
+ resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
+ deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
v8-compile-cache-lib@3.0.1:
@@ -2144,6 +2219,9 @@ packages:
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
+ whatwg-fetch@3.6.20:
+ resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==}
+
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -2170,8 +2248,8 @@ packages:
resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
- ws@8.19.0:
- resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
+ ws@8.20.1:
+ resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -2217,27 +2295,42 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
+ zod-to-json-schema@3.25.1:
+ resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
+ peerDependencies:
+ zod: ^3.25 || ^4
+
+ zod@4.4.3:
+ resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
+
snapshots:
- '@babel/code-frame@7.27.1':
+ '@anthropic-ai/sdk@0.100.1(zod@4.4.3)':
+ dependencies:
+ json-schema-to-ts: 3.1.1
+ standardwebhooks: 1.0.0
+ optionalDependencies:
+ zod: 4.4.3
+
+ '@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
js-tokens: 4.0.0
picocolors: 1.1.1
- '@babel/compat-data@7.28.5': {}
+ '@babel/compat-data@7.29.0': {}
- '@babel/core@7.28.5':
+ '@babel/core@7.29.0':
dependencies:
- '@babel/code-frame': 7.27.1
- '@babel/generator': 7.28.5
- '@babel/helper-compilation-targets': 7.27.2
- '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5)
- '@babel/helpers': 7.28.4
- '@babel/parser': 7.28.5
- '@babel/template': 7.27.2
- '@babel/traverse': 7.28.5
- '@babel/types': 7.28.5
+ '@babel/code-frame': 7.29.0
+ '@babel/generator': 7.29.1
+ '@babel/helper-compilation-targets': 7.28.6
+ '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
+ '@babel/helpers': 7.28.6
+ '@babel/parser': 7.29.0
+ '@babel/template': 7.28.6
+ '@babel/traverse': 7.29.0
+ '@babel/types': 7.29.0
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3
@@ -2247,41 +2340,41 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@babel/generator@7.28.5':
+ '@babel/generator@7.29.1':
dependencies:
- '@babel/parser': 7.28.5
- '@babel/types': 7.28.5
+ '@babel/parser': 7.29.0
+ '@babel/types': 7.29.0
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
- '@babel/helper-compilation-targets@7.27.2':
+ '@babel/helper-compilation-targets@7.28.6':
dependencies:
- '@babel/compat-data': 7.28.5
+ '@babel/compat-data': 7.29.0
'@babel/helper-validator-option': 7.27.1
- browserslist: 4.28.1
+ browserslist: 4.28.2
lru-cache: 5.1.1
semver: 6.3.1
'@babel/helper-globals@7.28.0': {}
- '@babel/helper-module-imports@7.27.1':
+ '@babel/helper-module-imports@7.28.6':
dependencies:
- '@babel/traverse': 7.28.5
- '@babel/types': 7.28.5
+ '@babel/traverse': 7.29.0
+ '@babel/types': 7.29.0
transitivePeerDependencies:
- supports-color
- '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)':
+ '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-module-imports': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-module-imports': 7.28.6
'@babel/helper-validator-identifier': 7.28.5
- '@babel/traverse': 7.28.5
+ '@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
- '@babel/helper-plugin-utils@7.27.1': {}
+ '@babel/helper-plugin-utils@7.28.6': {}
'@babel/helper-string-parser@7.27.1': {}
@@ -2289,119 +2382,121 @@ snapshots:
'@babel/helper-validator-option@7.27.1': {}
- '@babel/helpers@7.28.4':
+ '@babel/helpers@7.28.6':
dependencies:
- '@babel/template': 7.27.2
- '@babel/types': 7.28.5
+ '@babel/template': 7.28.6
+ '@babel/types': 7.29.0
- '@babel/parser@7.28.5':
+ '@babel/parser@7.29.0':
dependencies:
- '@babel/types': 7.28.5
+ '@babel/types': 7.29.0
- '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.5)':
+ '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)':
+ '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.5)':
+ '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.5)':
+ '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.5)':
+ '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)':
+ '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.5)':
+ '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)':
+ '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.5)':
+ '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.5)':
+ '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.5)':
+ '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.5)':
+ '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.5)':
+ '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.5)':
+ '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.5)':
+ '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.5)':
+ '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)':
+ '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)':
dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
- '@babel/template@7.27.2':
+ '@babel/runtime@7.29.2': {}
+
+ '@babel/template@7.28.6':
dependencies:
- '@babel/code-frame': 7.27.1
- '@babel/parser': 7.28.5
- '@babel/types': 7.28.5
+ '@babel/code-frame': 7.29.0
+ '@babel/parser': 7.29.0
+ '@babel/types': 7.29.0
- '@babel/traverse@7.28.5':
+ '@babel/traverse@7.29.0':
dependencies:
- '@babel/code-frame': 7.27.1
- '@babel/generator': 7.28.5
+ '@babel/code-frame': 7.29.0
+ '@babel/generator': 7.29.1
'@babel/helper-globals': 7.28.0
- '@babel/parser': 7.28.5
- '@babel/template': 7.27.2
- '@babel/types': 7.28.5
+ '@babel/parser': 7.29.0
+ '@babel/template': 7.28.6
+ '@babel/types': 7.29.0
debug: 4.4.3
transitivePeerDependencies:
- supports-color
- '@babel/types@7.28.5':
+ '@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
@@ -2412,105 +2507,107 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
- '@emnapi/core@1.7.1':
+ '@emnapi/core@1.10.0':
dependencies:
- '@emnapi/wasi-threads': 1.1.0
+ '@emnapi/wasi-threads': 1.2.1
tslib: 2.8.1
optional: true
- '@emnapi/runtime@1.7.1':
+ '@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
optional: true
- '@emnapi/wasi-threads@1.1.0':
+ '@emnapi/wasi-threads@1.2.1':
dependencies:
tslib: 2.8.1
optional: true
- '@esbuild/aix-ppc64@0.27.0':
+ '@esbuild/aix-ppc64@0.27.7':
optional: true
- '@esbuild/android-arm64@0.27.0':
+ '@esbuild/android-arm64@0.27.7':
optional: true
- '@esbuild/android-arm@0.27.0':
+ '@esbuild/android-arm@0.27.7':
optional: true
- '@esbuild/android-x64@0.27.0':
+ '@esbuild/android-x64@0.27.7':
optional: true
- '@esbuild/darwin-arm64@0.27.0':
+ '@esbuild/darwin-arm64@0.27.7':
optional: true
- '@esbuild/darwin-x64@0.27.0':
+ '@esbuild/darwin-x64@0.27.7':
optional: true
- '@esbuild/freebsd-arm64@0.27.0':
+ '@esbuild/freebsd-arm64@0.27.7':
optional: true
- '@esbuild/freebsd-x64@0.27.0':
+ '@esbuild/freebsd-x64@0.27.7':
optional: true
- '@esbuild/linux-arm64@0.27.0':
+ '@esbuild/linux-arm64@0.27.7':
optional: true
- '@esbuild/linux-arm@0.27.0':
+ '@esbuild/linux-arm@0.27.7':
optional: true
- '@esbuild/linux-ia32@0.27.0':
+ '@esbuild/linux-ia32@0.27.7':
optional: true
- '@esbuild/linux-loong64@0.27.0':
+ '@esbuild/linux-loong64@0.27.7':
optional: true
- '@esbuild/linux-mips64el@0.27.0':
+ '@esbuild/linux-mips64el@0.27.7':
optional: true
- '@esbuild/linux-ppc64@0.27.0':
+ '@esbuild/linux-ppc64@0.27.7':
optional: true
- '@esbuild/linux-riscv64@0.27.0':
+ '@esbuild/linux-riscv64@0.27.7':
optional: true
- '@esbuild/linux-s390x@0.27.0':
+ '@esbuild/linux-s390x@0.27.7':
optional: true
- '@esbuild/linux-x64@0.27.0':
+ '@esbuild/linux-x64@0.27.7':
optional: true
- '@esbuild/netbsd-arm64@0.27.0':
+ '@esbuild/netbsd-arm64@0.27.7':
optional: true
- '@esbuild/netbsd-x64@0.27.0':
+ '@esbuild/netbsd-x64@0.27.7':
optional: true
- '@esbuild/openbsd-arm64@0.27.0':
+ '@esbuild/openbsd-arm64@0.27.7':
optional: true
- '@esbuild/openbsd-x64@0.27.0':
+ '@esbuild/openbsd-x64@0.27.7':
optional: true
- '@esbuild/openharmony-arm64@0.27.0':
+ '@esbuild/openharmony-arm64@0.27.7':
optional: true
- '@esbuild/sunos-x64@0.27.0':
+ '@esbuild/sunos-x64@0.27.7':
optional: true
- '@esbuild/win32-arm64@0.27.0':
+ '@esbuild/win32-arm64@0.27.7':
optional: true
- '@esbuild/win32-ia32@0.27.0':
+ '@esbuild/win32-ia32@0.27.7':
optional: true
- '@esbuild/win32-x64@0.27.0':
+ '@esbuild/win32-x64@0.27.7':
optional: true
+ '@google/generative-ai@0.24.1': {}
+
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
string-width-cjs: string-width@4.2.3
- strip-ansi: 7.1.2
+ strip-ansi: 7.2.0
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
@@ -2528,13 +2625,13 @@ snapshots:
'@jest/console@30.2.0':
dependencies:
'@jest/types': 30.2.0
- '@types/node': 20.19.25
+ '@types/node': 20.19.41
chalk: 4.1.2
jest-message-util: 30.2.0
jest-util: 30.2.0
slash: 3.0.0
- '@jest/core@30.2.0(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))':
+ '@jest/core@30.2.0(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))':
dependencies:
'@jest/console': 30.2.0
'@jest/pattern': 30.0.1
@@ -2542,14 +2639,14 @@ snapshots:
'@jest/test-result': 30.2.0
'@jest/transform': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 20.19.25
+ '@types/node': 20.19.41
ansi-escapes: 4.3.2
chalk: 4.1.2
- ci-info: 4.3.1
+ ci-info: 4.4.0
exit-x: 0.2.2
graceful-fs: 4.2.11
jest-changed-files: 30.2.0
- jest-config: 30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
+ jest-config: 30.2.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
jest-haste-map: 30.2.0
jest-message-util: 30.2.0
jest-regex-util: 30.0.1
@@ -2576,7 +2673,7 @@ snapshots:
dependencies:
'@jest/fake-timers': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 20.19.25
+ '@types/node': 20.19.41
jest-mock: 30.2.0
'@jest/expect-utils@30.2.0':
@@ -2594,7 +2691,7 @@ snapshots:
dependencies:
'@jest/types': 30.2.0
'@sinonjs/fake-timers': 13.0.5
- '@types/node': 20.19.25
+ '@types/node': 20.19.41
jest-message-util: 30.2.0
jest-mock: 30.2.0
jest-util: 30.2.0
@@ -2612,7 +2709,7 @@ snapshots:
'@jest/pattern@30.0.1':
dependencies:
- '@types/node': 20.19.25
+ '@types/node': 20.19.41
jest-regex-util: 30.0.1
'@jest/reporters@30.2.0':
@@ -2623,7 +2720,7 @@ snapshots:
'@jest/transform': 30.2.0
'@jest/types': 30.2.0
'@jridgewell/trace-mapping': 0.3.31
- '@types/node': 20.19.25
+ '@types/node': 20.19.41
chalk: 4.1.2
collect-v8-coverage: 1.0.3
exit-x: 0.2.2
@@ -2676,7 +2773,7 @@ snapshots:
'@jest/transform@30.2.0':
dependencies:
- '@babel/core': 7.28.5
+ '@babel/core': 7.29.0
'@jest/types': 30.2.0
'@jridgewell/trace-mapping': 0.3.31
babel-plugin-istanbul: 7.0.1
@@ -2700,7 +2797,7 @@ snapshots:
'@jest/schemas': 30.0.5
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
- '@types/node': 20.19.25
+ '@types/node': 20.19.41
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -2728,6 +2825,15 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@mistralai/mistralai@2.2.5':
+ dependencies:
+ ws: 8.20.1
+ zod: 4.4.3
+ zod-to-json-schema: 3.25.1(zod@4.4.3)
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
'@napi-rs/keyring-darwin-arm64@1.2.0':
optional: true
@@ -2781,8 +2887,8 @@ snapshots:
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
- '@emnapi/core': 1.7.1
- '@emnapi/runtime': 1.7.1
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
'@tybys/wasm-util': 0.10.1
optional: true
@@ -2803,6 +2909,8 @@ snapshots:
dependencies:
'@sinonjs/commons': 3.0.1
+ '@stablelib/base64@1.0.1': {}
+
'@tsconfig/node10@1.0.12': {}
'@tsconfig/node12@1.0.11': {}
@@ -2818,28 +2926,28 @@ snapshots:
'@types/babel__core@7.20.5':
dependencies:
- '@babel/parser': 7.28.5
- '@babel/types': 7.28.5
+ '@babel/parser': 7.29.0
+ '@babel/types': 7.29.0
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
'@types/babel__traverse': 7.28.0
'@types/babel__generator@7.27.0':
dependencies:
- '@babel/types': 7.28.5
+ '@babel/types': 7.29.0
'@types/babel__template@7.4.4':
dependencies:
- '@babel/parser': 7.28.5
- '@babel/types': 7.28.5
+ '@babel/parser': 7.29.0
+ '@babel/types': 7.29.0
'@types/babel__traverse@7.28.0':
dependencies:
- '@babel/types': 7.28.5
+ '@babel/types': 7.29.0
'@types/better-sqlite3@7.6.13':
dependencies:
- '@types/node': 20.19.25
+ '@types/node': 20.19.41
'@types/istanbul-lib-coverage@2.0.6': {}
@@ -2860,14 +2968,14 @@ snapshots:
dependencies:
undici-types: 5.26.5
- '@types/node@20.19.25':
+ '@types/node@20.19.41':
dependencies:
undici-types: 6.21.0
- '@types/pg@8.15.6':
+ '@types/pg@8.20.0':
dependencies:
- '@types/node': 20.19.25
- pg-protocol: 1.10.3
+ '@types/node': 20.19.41
+ pg-protocol: 1.14.0
pg-types: 2.2.0
'@types/ssh2@1.15.5':
@@ -2953,7 +3061,7 @@ snapshots:
node-fetch: 2.7.0
picocolors: 1.1.1
progress: 2.0.3
- semver: 7.7.3
+ semver: 7.8.1
tar-fs: 2.1.4
yargs: 16.2.0
transitivePeerDependencies:
@@ -2962,28 +3070,28 @@ snapshots:
'@yao-pkg/pkg@5.16.1':
dependencies:
- '@babel/generator': 7.28.5
- '@babel/parser': 7.28.5
- '@babel/types': 7.28.5
+ '@babel/generator': 7.29.1
+ '@babel/parser': 7.29.0
+ '@babel/types': 7.29.0
'@yao-pkg/pkg-fetch': 3.5.16
into-stream: 6.0.0
minimist: 1.2.8
multistream: 4.1.0
picocolors: 1.1.1
- picomatch: 4.0.3
+ picomatch: 4.0.4
prebuild-install: 7.1.3
- resolve: 1.22.11
+ resolve: 1.22.12
stream-meter: 1.0.4
- tinyglobby: 0.2.15
+ tinyglobby: 0.2.16
transitivePeerDependencies:
- encoding
- supports-color
acorn-walk@8.3.4:
dependencies:
- acorn: 8.15.0
+ acorn: 8.16.0
- acorn@8.15.0: {}
+ acorn@8.16.0: {}
agent-base@6.0.2:
dependencies:
@@ -3010,7 +3118,7 @@ snapshots:
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
- picomatch: 2.3.1
+ picomatch: 2.3.2
arg@4.1.3: {}
@@ -3026,13 +3134,13 @@ snapshots:
aws-ssl-profiles@1.1.2: {}
- babel-jest@30.2.0(@babel/core@7.28.5):
+ babel-jest@30.2.0(@babel/core@7.29.0):
dependencies:
- '@babel/core': 7.28.5
+ '@babel/core': 7.29.0
'@jest/transform': 30.2.0
'@types/babel__core': 7.20.5
babel-plugin-istanbul: 7.0.1
- babel-preset-jest: 30.2.0(@babel/core@7.28.5)
+ babel-preset-jest: 30.2.0(@babel/core@7.29.0)
chalk: 4.1.2
graceful-fs: 4.2.11
slash: 3.0.0
@@ -3041,7 +3149,7 @@ snapshots:
babel-plugin-istanbul@7.0.1:
dependencies:
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/helper-plugin-utils': 7.28.6
'@istanbuljs/load-nyc-config': 1.1.0
'@istanbuljs/schema': 0.1.3
istanbul-lib-instrument: 6.0.3
@@ -3053,36 +3161,36 @@ snapshots:
dependencies:
'@types/babel__core': 7.20.5
- babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.5):
- dependencies:
- '@babel/core': 7.28.5
- '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.5)
- '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.5)
- '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.5)
- '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.5)
- '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.5)
- '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.5)
- '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.5)
- '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.5)
- '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.5)
- '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.5)
- '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.5)
- '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.5)
- '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.5)
- '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.5)
- '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.5)
-
- babel-preset-jest@30.2.0(@babel/core@7.28.5):
- dependencies:
- '@babel/core': 7.28.5
+ babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0):
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0)
+ '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0)
+ '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0)
+ '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0)
+ '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.29.0)
+ '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0)
+ '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0)
+ '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0)
+ '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0)
+ '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0)
+ '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0)
+ '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0)
+ '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0)
+ '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0)
+ '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0)
+
+ babel-preset-jest@30.2.0(@babel/core@7.29.0):
+ dependencies:
+ '@babel/core': 7.29.0
babel-plugin-jest-hoist: 30.2.0
- babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5)
+ babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0)
balanced-match@1.0.2: {}
base64-js@1.5.1: {}
- baseline-browser-mapping@2.9.5: {}
+ baseline-browser-mapping@2.10.31: {}
bcrypt-pbkdf@1.0.2:
dependencies:
@@ -3090,7 +3198,7 @@ snapshots:
bcryptjs@3.0.3: {}
- better-sqlite3@11.9.0:
+ better-sqlite3@11.10.0:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
@@ -3107,12 +3215,12 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
- brace-expansion@1.1.12:
+ brace-expansion@1.1.14:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
- brace-expansion@2.0.2:
+ brace-expansion@2.1.0:
dependencies:
balanced-match: 1.0.2
@@ -3120,13 +3228,13 @@ snapshots:
dependencies:
fill-range: 7.1.1
- browserslist@4.28.1:
+ browserslist@4.28.2:
dependencies:
- baseline-browser-mapping: 2.9.5
- caniuse-lite: 1.0.30001760
- electron-to-chromium: 1.5.267
- node-releases: 2.0.27
- update-browserslist-db: 1.2.2(browserslist@4.28.1)
+ baseline-browser-mapping: 2.10.31
+ caniuse-lite: 1.0.30001793
+ electron-to-chromium: 1.5.361
+ node-releases: 2.0.46
+ update-browserslist-db: 1.2.3(browserslist@4.28.2)
bs-logger@0.2.6:
dependencies:
@@ -3152,7 +3260,7 @@ snapshots:
camelcase@6.3.0: {}
- caniuse-lite@1.0.30001760: {}
+ caniuse-lite@1.0.30001793: {}
chalk@4.1.2:
dependencies:
@@ -3175,7 +3283,7 @@ snapshots:
chownr@1.1.4: {}
- ci-info@4.3.1: {}
+ ci-info@4.4.0: {}
cjs-module-lexer@2.1.1: {}
@@ -3229,7 +3337,7 @@ snapshots:
dependencies:
mimic-response: 3.1.0
- dedent@1.7.0: {}
+ dedent@1.7.2: {}
deep-extend@0.6.0: {}
@@ -3241,9 +3349,9 @@ snapshots:
detect-newline@3.1.0: {}
- diff@4.0.2: {}
+ diff@4.0.4: {}
- dotenv@17.2.3: {}
+ dotenv@17.4.2: {}
dynamic-dedupe@0.3.0:
dependencies:
@@ -3251,7 +3359,7 @@ snapshots:
eastasianwidth@0.2.0: {}
- electron-to-chromium@1.5.267: {}
+ electron-to-chromium@1.5.361: {}
emittery@0.13.1: {}
@@ -3267,34 +3375,36 @@ snapshots:
dependencies:
is-arrayish: 0.2.1
- esbuild@0.27.0:
+ es-errors@1.3.0: {}
+
+ esbuild@0.27.7:
optionalDependencies:
- '@esbuild/aix-ppc64': 0.27.0
- '@esbuild/android-arm': 0.27.0
- '@esbuild/android-arm64': 0.27.0
- '@esbuild/android-x64': 0.27.0
- '@esbuild/darwin-arm64': 0.27.0
- '@esbuild/darwin-x64': 0.27.0
- '@esbuild/freebsd-arm64': 0.27.0
- '@esbuild/freebsd-x64': 0.27.0
- '@esbuild/linux-arm': 0.27.0
- '@esbuild/linux-arm64': 0.27.0
- '@esbuild/linux-ia32': 0.27.0
- '@esbuild/linux-loong64': 0.27.0
- '@esbuild/linux-mips64el': 0.27.0
- '@esbuild/linux-ppc64': 0.27.0
- '@esbuild/linux-riscv64': 0.27.0
- '@esbuild/linux-s390x': 0.27.0
- '@esbuild/linux-x64': 0.27.0
- '@esbuild/netbsd-arm64': 0.27.0
- '@esbuild/netbsd-x64': 0.27.0
- '@esbuild/openbsd-arm64': 0.27.0
- '@esbuild/openbsd-x64': 0.27.0
- '@esbuild/openharmony-arm64': 0.27.0
- '@esbuild/sunos-x64': 0.27.0
- '@esbuild/win32-arm64': 0.27.0
- '@esbuild/win32-ia32': 0.27.0
- '@esbuild/win32-x64': 0.27.0
+ '@esbuild/aix-ppc64': 0.27.7
+ '@esbuild/android-arm': 0.27.7
+ '@esbuild/android-arm64': 0.27.7
+ '@esbuild/android-x64': 0.27.7
+ '@esbuild/darwin-arm64': 0.27.7
+ '@esbuild/darwin-x64': 0.27.7
+ '@esbuild/freebsd-arm64': 0.27.7
+ '@esbuild/freebsd-x64': 0.27.7
+ '@esbuild/linux-arm': 0.27.7
+ '@esbuild/linux-arm64': 0.27.7
+ '@esbuild/linux-ia32': 0.27.7
+ '@esbuild/linux-loong64': 0.27.7
+ '@esbuild/linux-mips64el': 0.27.7
+ '@esbuild/linux-ppc64': 0.27.7
+ '@esbuild/linux-riscv64': 0.27.7
+ '@esbuild/linux-s390x': 0.27.7
+ '@esbuild/linux-x64': 0.27.7
+ '@esbuild/netbsd-arm64': 0.27.7
+ '@esbuild/netbsd-x64': 0.27.7
+ '@esbuild/openbsd-arm64': 0.27.7
+ '@esbuild/openbsd-x64': 0.27.7
+ '@esbuild/openharmony-arm64': 0.27.7
+ '@esbuild/sunos-x64': 0.27.7
+ '@esbuild/win32-arm64': 0.27.7
+ '@esbuild/win32-ia32': 0.27.7
+ '@esbuild/win32-x64': 0.27.7
escalade@3.2.0: {}
@@ -3329,13 +3439,15 @@ snapshots:
fast-json-stable-stringify@2.1.0: {}
+ fast-sha256@1.3.0: {}
+
fb-watchman@2.0.2:
dependencies:
bser: 2.1.1
- fdir@6.5.0(picomatch@4.0.3):
+ fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
- picomatch: 4.0.3
+ picomatch: 4.0.4
file-uri-to-path@1.0.0: {}
@@ -3389,7 +3501,7 @@ snapshots:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
- minimatch: 9.0.5
+ minimatch: 9.0.9
minipass: 7.1.2
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
@@ -3399,12 +3511,14 @@ snapshots:
fs.realpath: 1.0.0
inflight: 1.0.6
inherits: 2.0.4
- minimatch: 3.1.2
+ minimatch: 3.1.5
once: 1.4.0
path-is-absolute: 1.0.1
graceful-fs@4.2.11: {}
+ groq-sdk@1.2.1: {}
+
handlebars@4.7.8:
dependencies:
minimist: 1.2.8
@@ -3431,7 +3545,7 @@ snapshots:
human-signals@2.1.0: {}
- iconv-lite@0.7.0:
+ iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
@@ -3492,11 +3606,11 @@ snapshots:
istanbul-lib-instrument@6.0.3:
dependencies:
- '@babel/core': 7.28.5
- '@babel/parser': 7.28.5
+ '@babel/core': 7.29.0
+ '@babel/parser': 7.29.0
'@istanbuljs/schema': 0.1.3
istanbul-lib-coverage: 3.2.2
- semver: 7.7.3
+ semver: 7.8.1
transitivePeerDependencies:
- supports-color
@@ -3537,10 +3651,10 @@ snapshots:
'@jest/expect': 30.2.0
'@jest/test-result': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 20.19.25
+ '@types/node': 20.19.41
chalk: 4.1.2
co: 4.6.0
- dedent: 1.7.0
+ dedent: 1.7.2
is-generator-fn: 2.1.0
jest-each: 30.2.0
jest-matcher-utils: 30.2.0
@@ -3557,15 +3671,15 @@ snapshots:
- babel-plugin-macros
- supports-color
- jest-cli@30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)):
+ jest-cli@30.2.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)):
dependencies:
- '@jest/core': 30.2.0(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
+ '@jest/core': 30.2.0(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
'@jest/test-result': 30.2.0
'@jest/types': 30.2.0
chalk: 4.1.2
exit-x: 0.2.2
import-local: 3.2.0
- jest-config: 30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
+ jest-config: 30.2.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
jest-util: 30.2.0
jest-validate: 30.2.0
yargs: 17.7.2
@@ -3576,16 +3690,16 @@ snapshots:
- supports-color
- ts-node
- jest-config@30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)):
+ jest-config@30.2.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)):
dependencies:
- '@babel/core': 7.28.5
+ '@babel/core': 7.29.0
'@jest/get-type': 30.1.0
'@jest/pattern': 30.0.1
'@jest/test-sequencer': 30.2.0
'@jest/types': 30.2.0
- babel-jest: 30.2.0(@babel/core@7.28.5)
+ babel-jest: 30.2.0(@babel/core@7.29.0)
chalk: 4.1.2
- ci-info: 4.3.1
+ ci-info: 4.4.0
deepmerge: 4.3.1
glob: 10.5.0
graceful-fs: 4.2.11
@@ -3603,8 +3717,8 @@ snapshots:
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
- '@types/node': 20.19.25
- ts-node: 10.9.2(@types/node@20.19.25)(typescript@5.9.3)
+ '@types/node': 20.19.41
+ ts-node: 10.9.2(@types/node@20.19.41)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -3633,7 +3747,7 @@ snapshots:
'@jest/environment': 30.2.0
'@jest/fake-timers': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 20.19.25
+ '@types/node': 20.19.41
jest-mock: 30.2.0
jest-util: 30.2.0
jest-validate: 30.2.0
@@ -3641,7 +3755,7 @@ snapshots:
jest-haste-map@30.2.0:
dependencies:
'@jest/types': 30.2.0
- '@types/node': 20.19.25
+ '@types/node': 20.19.41
anymatch: 3.1.3
fb-watchman: 2.0.2
graceful-fs: 4.2.11
@@ -3667,7 +3781,7 @@ snapshots:
jest-message-util@30.2.0:
dependencies:
- '@babel/code-frame': 7.27.1
+ '@babel/code-frame': 7.29.0
'@jest/types': 30.2.0
'@types/stack-utils': 2.0.3
chalk: 4.1.2
@@ -3680,7 +3794,7 @@ snapshots:
jest-mock@30.2.0:
dependencies:
'@jest/types': 30.2.0
- '@types/node': 20.19.25
+ '@types/node': 20.19.41
jest-util: 30.2.0
jest-pnp-resolver@1.2.3(jest-resolve@30.2.0):
@@ -3714,7 +3828,7 @@ snapshots:
'@jest/test-result': 30.2.0
'@jest/transform': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 20.19.25
+ '@types/node': 20.19.41
chalk: 4.1.2
emittery: 0.13.1
exit-x: 0.2.2
@@ -3743,7 +3857,7 @@ snapshots:
'@jest/test-result': 30.2.0
'@jest/transform': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 20.19.25
+ '@types/node': 20.19.41
chalk: 4.1.2
cjs-module-lexer: 2.1.1
collect-v8-coverage: 1.0.3
@@ -3763,17 +3877,17 @@ snapshots:
jest-snapshot@30.2.0:
dependencies:
- '@babel/core': 7.28.5
- '@babel/generator': 7.28.5
- '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5)
- '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5)
- '@babel/types': 7.28.5
+ '@babel/core': 7.29.0
+ '@babel/generator': 7.29.1
+ '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
+ '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0)
+ '@babel/types': 7.29.0
'@jest/expect-utils': 30.2.0
'@jest/get-type': 30.1.0
'@jest/snapshot-utils': 30.2.0
'@jest/transform': 30.2.0
'@jest/types': 30.2.0
- babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5)
+ babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0)
chalk: 4.1.2
expect: 30.2.0
graceful-fs: 4.2.11
@@ -3782,7 +3896,7 @@ snapshots:
jest-message-util: 30.2.0
jest-util: 30.2.0
pretty-format: 30.2.0
- semver: 7.7.3
+ semver: 7.8.1
synckit: 0.11.11
transitivePeerDependencies:
- supports-color
@@ -3790,11 +3904,11 @@ snapshots:
jest-util@30.2.0:
dependencies:
'@jest/types': 30.2.0
- '@types/node': 20.19.25
+ '@types/node': 20.19.41
chalk: 4.1.2
- ci-info: 4.3.1
+ ci-info: 4.4.0
graceful-fs: 4.2.11
- picomatch: 4.0.3
+ picomatch: 4.0.4
jest-validate@30.2.0:
dependencies:
@@ -3809,7 +3923,7 @@ snapshots:
dependencies:
'@jest/test-result': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 20.19.25
+ '@types/node': 20.19.41
ansi-escapes: 4.3.2
chalk: 4.1.2
emittery: 0.13.1
@@ -3818,18 +3932,18 @@ snapshots:
jest-worker@30.2.0:
dependencies:
- '@types/node': 20.19.25
+ '@types/node': 20.19.41
'@ungap/structured-clone': 1.3.0
jest-util: 30.2.0
merge-stream: 2.0.0
supports-color: 8.1.1
- jest@30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)):
+ jest@30.2.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)):
dependencies:
- '@jest/core': 30.2.0(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
+ '@jest/core': 30.2.0(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
'@jest/types': 30.2.0
import-local: 3.2.0
- jest-cli: 30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
+ jest-cli: 30.2.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -3848,6 +3962,11 @@ snapshots:
json-parse-even-better-errors@2.3.1: {}
+ json-schema-to-ts@3.1.1:
+ dependencies:
+ '@babel/runtime': 7.29.2
+ ts-algebra: 2.0.0
+
json5@2.2.3: {}
leven@3.1.0: {}
@@ -3868,13 +3987,11 @@ snapshots:
dependencies:
yallist: 3.1.1
- lru-cache@7.18.3: {}
-
- lru.min@1.1.3: {}
+ lru.min@1.1.4: {}
make-dir@4.0.0:
dependencies:
- semver: 7.7.3
+ semver: 7.8.1
make-error@1.3.6: {}
@@ -3887,19 +4004,19 @@ snapshots:
micromatch@4.0.8:
dependencies:
braces: 3.0.3
- picomatch: 2.3.1
+ picomatch: 2.3.2
mimic-fn@2.1.0: {}
mimic-response@3.1.0: {}
- minimatch@3.1.2:
+ minimatch@3.1.5:
dependencies:
- brace-expansion: 1.1.12
+ brace-expansion: 1.1.14
- minimatch@9.0.5:
+ minimatch@9.0.9:
dependencies:
- brace-expansion: 2.0.2
+ brace-expansion: 2.1.0
minimist@1.2.8: {}
@@ -3921,16 +4038,16 @@ snapshots:
aws-ssl-profiles: 1.1.2
denque: 2.1.0
generate-function: 2.3.1
- iconv-lite: 0.7.0
+ iconv-lite: 0.7.2
long: 5.3.2
- lru.min: 1.1.3
- named-placeholders: 1.1.3
+ lru.min: 1.1.4
+ named-placeholders: 1.1.6
seq-queue: 0.0.5
sqlstring: 2.3.3
- named-placeholders@1.1.3:
+ named-placeholders@1.1.6:
dependencies:
- lru-cache: 7.18.3
+ lru.min: 1.1.4
nan@2.25.0:
optional: true
@@ -3943,9 +4060,9 @@ snapshots:
neo-async@2.6.2: {}
- node-abi@3.85.0:
+ node-abi@3.87.0:
dependencies:
- semver: 7.7.3
+ semver: 7.8.1
node-fetch@2.7.0:
dependencies:
@@ -3953,7 +4070,7 @@ snapshots:
node-int64@0.4.0: {}
- node-releases@2.0.27: {}
+ node-releases@2.0.46: {}
normalize-path@3.0.0: {}
@@ -3961,6 +4078,10 @@ snapshots:
dependencies:
path-key: 3.1.1
+ ollama@0.6.3:
+ dependencies:
+ whatwg-fetch: 3.6.20
+
on-exit-leak-free@2.1.2: {}
once@1.4.0:
@@ -3971,6 +4092,11 @@ snapshots:
dependencies:
mimic-fn: 2.1.0
+ openai@6.41.0(ws@8.20.1)(zod@4.4.3):
+ optionalDependencies:
+ ws: 8.20.1
+ zod: 4.4.3
+
p-is-promise@3.0.0: {}
p-limit@2.3.0:
@@ -3991,7 +4117,7 @@ snapshots:
parse-json@5.2.0:
dependencies:
- '@babel/code-frame': 7.27.1
+ '@babel/code-frame': 7.29.0
error-ex: 1.3.4
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
@@ -4009,45 +4135,45 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.2
- pg-cloudflare@1.2.7:
+ pg-cloudflare@1.4.0:
optional: true
- pg-connection-string@2.9.1: {}
+ pg-connection-string@2.13.0: {}
- pg-cursor@2.15.3(pg@8.16.3):
+ pg-cursor@2.15.3(pg@8.21.0):
dependencies:
- pg: 8.16.3
+ pg: 8.21.0
pg-int8@1.0.1: {}
- pg-pool@3.10.1(pg@8.16.3):
+ pg-pool@3.14.0(pg@8.21.0):
dependencies:
- pg: 8.16.3
+ pg: 8.21.0
- pg-protocol@1.10.3: {}
+ pg-protocol@1.14.0: {}
- pg-query-stream@4.10.3(pg@8.16.3):
+ pg-query-stream@4.10.3(pg@8.21.0):
dependencies:
- pg: 8.16.3
- pg-cursor: 2.15.3(pg@8.16.3)
+ pg: 8.21.0
+ pg-cursor: 2.15.3(pg@8.21.0)
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
postgres-array: 2.0.0
- postgres-bytea: 1.0.0
+ postgres-bytea: 1.0.1
postgres-date: 1.0.7
postgres-interval: 1.2.0
- pg@8.16.3:
+ pg@8.21.0:
dependencies:
- pg-connection-string: 2.9.1
- pg-pool: 3.10.1(pg@8.16.3)
- pg-protocol: 1.10.3
+ pg-connection-string: 2.13.0
+ pg-pool: 3.14.0(pg@8.21.0)
+ pg-protocol: 1.14.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
- pg-cloudflare: 1.2.7
+ pg-cloudflare: 1.4.0
pgpass@1.0.5:
dependencies:
@@ -4055,9 +4181,9 @@ snapshots:
picocolors@1.1.1: {}
- picomatch@2.3.1: {}
+ picomatch@2.3.2: {}
- picomatch@4.0.3: {}
+ picomatch@4.0.4: {}
pino-abstract-transport@2.0.0:
dependencies:
@@ -4087,7 +4213,7 @@ snapshots:
postgres-array@2.0.0: {}
- postgres-bytea@1.0.0: {}
+ postgres-bytea@1.0.1: {}
postgres-date@1.0.7: {}
@@ -4103,8 +4229,8 @@ snapshots:
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
- node-abi: 3.85.0
- pump: 3.0.3
+ node-abi: 3.87.0
+ pump: 3.0.4
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
@@ -4122,7 +4248,7 @@ snapshots:
progress@2.0.3: {}
- pump@3.0.3:
+ pump@3.0.4:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
@@ -4158,7 +4284,7 @@ snapshots:
readdirp@3.6.0:
dependencies:
- picomatch: 2.3.1
+ picomatch: 2.3.2
real-require@0.2.0: {}
@@ -4170,8 +4296,9 @@ snapshots:
resolve-from@5.0.0: {}
- resolve@1.22.11:
+ resolve@1.22.12:
dependencies:
+ es-errors: 1.3.0
is-core-module: 2.16.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
@@ -4190,7 +4317,7 @@ snapshots:
semver@6.3.1: {}
- semver@7.7.3: {}
+ semver@7.8.1: {}
seq-queue@0.0.5: {}
@@ -4248,6 +4375,11 @@ snapshots:
dependencies:
escape-string-regexp: 2.0.0
+ standardwebhooks@1.0.0:
+ dependencies:
+ '@stablelib/base64': 1.0.1
+ fast-sha256: 1.3.0
+
stream-meter@1.0.4:
dependencies:
readable-stream: 2.3.8
@@ -4267,7 +4399,7 @@ snapshots:
dependencies:
eastasianwidth: 0.2.0
emoji-regex: 9.2.2
- strip-ansi: 7.1.2
+ strip-ansi: 7.2.0
string_decoder@1.1.1:
dependencies:
@@ -4281,7 +4413,7 @@ snapshots:
dependencies:
ansi-regex: 5.0.1
- strip-ansi@7.1.2:
+ strip-ansi@7.2.0:
dependencies:
ansi-regex: 6.2.2
@@ -4313,7 +4445,7 @@ snapshots:
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
- pump: 3.0.3
+ pump: 3.0.4
tar-stream: 2.2.0
tar-stream@2.2.0:
@@ -4328,16 +4460,16 @@ snapshots:
dependencies:
'@istanbuljs/schema': 0.1.3
glob: 7.2.3
- minimatch: 3.1.2
+ minimatch: 3.1.5
thread-stream@3.1.0:
dependencies:
real-require: 0.2.0
- tinyglobby@0.2.15:
+ tinyglobby@0.2.16:
dependencies:
- fdir: 6.5.0(picomatch@4.0.3)
- picomatch: 4.0.3
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
tmpl@1.0.5: {}
@@ -4349,38 +4481,40 @@ snapshots:
tree-kill@1.2.2: {}
- ts-jest@29.4.6(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)))(typescript@5.9.3):
+ ts-algebra@2.0.0: {}
+
+ ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(esbuild@0.27.7)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.8
- jest: 30.2.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
+ jest: 30.2.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
- semver: 7.7.3
+ semver: 7.8.1
type-fest: 4.41.0
typescript: 5.9.3
yargs-parser: 21.1.1
optionalDependencies:
- '@babel/core': 7.28.5
+ '@babel/core': 7.29.0
'@jest/transform': 30.2.0
'@jest/types': 30.2.0
- babel-jest: 30.2.0(@babel/core@7.28.5)
- esbuild: 0.27.0
+ babel-jest: 30.2.0(@babel/core@7.29.0)
+ esbuild: 0.27.7
jest-util: 30.2.0
- ts-node-dev@2.0.0(@types/node@20.19.25)(typescript@5.9.3):
+ ts-node-dev@2.0.0(@types/node@20.19.41)(typescript@5.9.3):
dependencies:
chokidar: 3.6.0
dynamic-dedupe: 0.3.0
minimist: 1.2.8
mkdirp: 1.0.4
- resolve: 1.22.11
+ resolve: 1.22.12
rimraf: 2.7.1
source-map-support: 0.5.21
tree-kill: 1.2.2
- ts-node: 10.9.2(@types/node@20.19.25)(typescript@5.9.3)
+ ts-node: 10.9.2(@types/node@20.19.41)(typescript@5.9.3)
tsconfig: 7.0.0
typescript: 5.9.3
transitivePeerDependencies:
@@ -4388,19 +4522,19 @@ snapshots:
- '@swc/wasm'
- '@types/node'
- ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3):
+ ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.12
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
- '@types/node': 20.19.25
- acorn: 8.15.0
+ '@types/node': 20.19.41
+ acorn: 8.16.0
acorn-walk: 8.3.4
arg: 4.1.3
create-require: 1.1.1
- diff: 4.0.2
+ diff: 4.0.4
make-error: 1.3.6
typescript: 5.9.3
v8-compile-cache-lib: 3.0.1
@@ -4461,15 +4595,15 @@ snapshots:
'@unrs/resolver-binding-win32-ia32-msvc': 1.11.1
'@unrs/resolver-binding-win32-x64-msvc': 1.11.1
- update-browserslist-db@1.2.2(browserslist@4.28.1):
+ update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies:
- browserslist: 4.28.1
+ browserslist: 4.28.2
escalade: 3.2.0
picocolors: 1.1.1
util-deprecate@1.0.2: {}
- uuid@8.3.2: {}
+ uuid@9.0.1: {}
v8-compile-cache-lib@3.0.1: {}
@@ -4485,6 +4619,8 @@ snapshots:
webidl-conversions@3.0.1: {}
+ whatwg-fetch@3.6.20: {}
+
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
@@ -4506,7 +4642,7 @@ snapshots:
dependencies:
ansi-styles: 6.2.3
string-width: 5.1.2
- strip-ansi: 7.1.2
+ strip-ansi: 7.2.0
wrappy@1.0.2: {}
@@ -4515,7 +4651,7 @@ snapshots:
imurmurhash: 0.1.4
signal-exit: 4.1.0
- ws@8.19.0: {}
+ ws@8.20.1: {}
xtend@4.0.2: {}
@@ -4550,3 +4686,9 @@ snapshots:
yn@3.1.1: {}
yocto-queue@0.1.0: {}
+
+ zod-to-json-schema@3.25.1(zod@4.4.3):
+ dependencies:
+ zod: 4.4.3
+
+ zod@4.4.3: {}
diff --git a/bridge/src/ai/README.md b/bridge/src/ai/README.md
new file mode 100644
index 0000000..0746cdf
--- /dev/null
+++ b/bridge/src/ai/README.md
@@ -0,0 +1,23 @@
+# AI Integration (Bridge)
+
+This folder contains AI provider implementations and prompt templates used by the bridge.
+
+Layout
+- `providers/` — concrete provider adapters (OpenAI, Anthropic, Gemini, Mistral, Ollama, Groq). Implement the `AIProvider` interface in `providers/types.ts`.
+- `prompts/` — prompt builders and parsers for schema analysis, query explanation and chart recommendation.
+
+What moved
+- The public RPC entry points and handler logic live under `src/handlers/aiHandlers.ts`.
+- The service factory implementation was moved to `src/services/ai.impl.ts` and a shim `src/services/aiService.ts` exposes `AIService` and `aiService` for consistency with other bridge services.
+- Shared AI types were moved to `src/types/ai.ts` and are re-exported from the central `src/types/index.ts`.
+
+How to add a provider
+1. Create a new file under `providers/` implementing the `AIProvider` interface.
+2. Add the provider into `src/services/ai.impl.ts` in the `createProvider` switch.
+3. Add any provider-specific configuration notes to this README.
+
+Testing
+- No tests currently exist for AI. To exercise the integration locally, use the frontend UI features that call `ai.*` RPC methods or create a test that uses the RPC registrar.
+
+Notes
+- Keep providers small and avoid direct network retries; the bridge surface should translate provider errors into `AIError` using `providers/types.ts`.
diff --git a/bridge/src/ai/prompts/chart-recommendation.ts b/bridge/src/ai/prompts/chart-recommendation.ts
new file mode 100644
index 0000000..bb5316e
--- /dev/null
+++ b/bridge/src/ai/prompts/chart-recommendation.ts
@@ -0,0 +1,66 @@
+import { ChartRecommendationInput, ChartRecommendation } from "../../types/";
+import { SYSTEM_CONTEXT } from "./shared";
+import { AIError } from "../providers/types";
+
+export function buildChartRecommendationPrompt(input: ChartRecommendationInput): {
+ system: string;
+ user: string;
+} {
+ const columnList = input.columns
+ .map((c) => {
+ const flags: string[] = [c.type];
+ if (c.isPrimaryKey) flags.push("PK");
+ if (c.sampleValues?.length) flags.push(`samples: ${c.sampleValues.slice(0, 3).join(", ")}`);
+ return `- ${c.name} (${flags.join(", ")})`;
+ })
+ .join("\n");
+
+ const user = `Given the table "${input.tableName}" with the following columns, recommend the best chart visualization:
+
+## Columns
+${columnList}
+
+## Instructions
+Respond ONLY with a valid JSON object — no markdown fences, no explanation text.
+The JSON must match exactly this shape:
+{
+ "chartType": "bar" | "line" | "area" | "pie",
+ "xAxis": "",
+ "yAxis": "",
+ "reasoning": ""
+}
+
+Choose the most insightful combination. Prefer grouping a categorical/text column on X and counting a numeric/PK column on Y.`;
+
+ return { system: SYSTEM_CONTEXT, user };
+}
+
+/**
+ * Parse the raw LLM text response into a ChartRecommendation.
+ * The model is instructed to return bare JSON, but defensively strip
+ * any markdown fences if the model adds them anyway.
+ */
+export function parseChartRecommendation(raw: string): ChartRecommendation {
+ // Strip markdown code fences if present
+ const cleaned = raw
+ .replace(/```json\s*/gi, "")
+ .replace(/```\s*/g, "")
+ .trim();
+
+ let parsed: any;
+ try {
+ parsed = JSON.parse(cleaned);
+ } catch {
+ throw new AIError("PARSE_ERROR", "chart-recommendation", `Failed to parse chart recommendation JSON: ${raw.slice(0, 200)}`);
+ }
+
+ const validTypes = ["bar", "line", "area", "pie"];
+ const chartType = validTypes.includes(parsed.chartType) ? parsed.chartType : "bar";
+
+ return {
+ chartType: chartType as ChartRecommendation["chartType"],
+ xAxis: String(parsed.xAxis ?? ""),
+ yAxis: String(parsed.yAxis ?? ""),
+ reasoning: String(parsed.reasoning ?? ""),
+ };
+}
diff --git a/bridge/src/ai/prompts/query-explanation.ts b/bridge/src/ai/prompts/query-explanation.ts
new file mode 100644
index 0000000..1897456
--- /dev/null
+++ b/bridge/src/ai/prompts/query-explanation.ts
@@ -0,0 +1,42 @@
+import { QueryExplanationInput } from "../../types/";
+import { SYSTEM_CONTEXT, MARKDOWN_INSTRUCTION } from "./shared";
+
+export function buildQueryExplanationPrompt(input: QueryExplanationInput): {
+ system: string;
+ user: string;
+} {
+ const schemaContext =
+ input.schema && input.schema.length > 0
+ ? input.schema
+ .map((t) => {
+ const cols = t.columns
+ .map((c) => `${c.name} ${c.type}${c.isPrimaryKey ? " PK" : ""}${c.isForeignKey ? " FK" : ""}`)
+ .join(", ");
+ return `- ${t.name}(${cols})`;
+ })
+ .join("\n")
+ : "Schema not provided.";
+
+ const dbType = input.databaseType ? ` (${input.databaseType})` : "";
+
+ const user = `Explain the following SQL query${dbType}:
+
+\`\`\`sql
+${input.sql}
+\`\`\`
+
+## Relevant Schema
+${schemaContext}
+
+## What to cover
+1. **Query Purpose** — What does this query do in plain English?
+2. **Joins** — Explain any joins and the relationships they traverse
+3. **Filters** — What data is being filtered and why
+4. **Aggregations** — Any GROUP BY, COUNT, SUM, etc., and what they compute
+5. **Performance Concerns** — Potential bottlenecks, missing indexes, full table scans
+6. **Suggested Improvements** — Rewritten or optimized version if applicable
+
+${MARKDOWN_INSTRUCTION}`;
+
+ return { system: SYSTEM_CONTEXT, user };
+}
diff --git a/bridge/src/ai/prompts/schema-analysis.ts b/bridge/src/ai/prompts/schema-analysis.ts
new file mode 100644
index 0000000..8d9a79d
--- /dev/null
+++ b/bridge/src/ai/prompts/schema-analysis.ts
@@ -0,0 +1,52 @@
+import { SchemaAnalysisInput } from "../../types/";
+import { SYSTEM_CONTEXT, MARKDOWN_INSTRUCTION } from "./shared";
+
+export function buildSchemaAnalysisPrompt(input: SchemaAnalysisInput): {
+ system: string;
+ user: string;
+} {
+ const tableDescriptions = input.tables
+ .map((t) => {
+ const columns = t.columns
+ .map((c) => {
+ const flags: string[] = [];
+ if (c.isPrimaryKey) flags.push("PK");
+ if (c.isForeignKey) flags.push("FK");
+ if (!c.nullable) flags.push("NOT NULL");
+ if (c.references) flags.push(`→${c.references.table}.${c.references.column}`);
+ return `${c.name}(${c.type})${flags.length ? "[" + flags.join(",") + "]" : ""}`;
+ })
+ .join(", ");
+
+ const extras: string[] = [];
+ if (t.indexes?.length) extras.push(`idx: ${t.indexes.join(",")}`);
+ if (t.foreignKeys?.length) extras.push(`fk: ${t.foreignKeys.join(",")}`);
+ if (t.constraints?.length) extras.push(`cons: ${t.constraints.join(",")}`);
+
+ const tableName = `${t.schema ? `${t.schema}.` : ""}${t.name}`;
+ const extrasStr = extras.length ? ` | ${extras.join(" | ")}` : "";
+
+ return `[${tableName}] cols: ${columns}${extrasStr}`;
+ })
+ .join("\n");
+
+ const dbType = input.databaseType ? ` (${input.databaseType})` : "";
+
+ const user = `Analyze the following database schema${dbType} and provide:
+
+## What to cover
+1. **Purpose** — What does this database appear to be for?
+2. **Architecture** — Key design decisions and table relationships
+3. **Missing Indexes** — Columns that should be indexed but aren't
+4. **Schema Smells** — Anti-patterns, naming issues, or poor design choices
+5. **Normalization Concerns** — Over/under-normalization issues
+6. **Scalability Concerns** — Issues that may cause problems at scale
+7. **Suggested Improvements** — Concrete, actionable recommendations
+
+## Schema
+${tableDescriptions || "No tables provided."}
+
+${MARKDOWN_INSTRUCTION}`;
+
+ return { system: SYSTEM_CONTEXT, user };
+}
diff --git a/bridge/src/ai/prompts/shared.ts b/bridge/src/ai/prompts/shared.ts
new file mode 100644
index 0000000..2a7bcc6
--- /dev/null
+++ b/bridge/src/ai/prompts/shared.ts
@@ -0,0 +1,18 @@
+/**
+ * Shared prompt fragments used across all AI providers.
+ * Keep these provider-independent — no SDK-specific formatting here.
+ */
+
+export const SYSTEM_CONTEXT = `You are RelWave AI, an expert database assistant embedded in the RelWave desktop application.
+RelWave helps developers manage PostgreSQL, MySQL, MariaDB, and SQLite databases.
+Always respond in clear, well-structured Markdown unless instructed otherwise.
+Be concise, practical, and actionable. Avoid boilerplate preambles.`;
+
+export const MARKDOWN_INSTRUCTION = `Format your response in Markdown with:
+- Level 2 headings (##) for major sections
+- Bullet points for lists of items
+- Code blocks (\`\`\`sql) for SQL examples
+- Bold text for key terms and important warnings
+Keep responses focused and under 1000 words unless the complexity demands more.`;
+
+export const NO_DATA_PROMPT = "No structured data was provided.";
diff --git a/bridge/src/ai/providers/anthropic.provider.ts b/bridge/src/ai/providers/anthropic.provider.ts
new file mode 100644
index 0000000..dac87c9
--- /dev/null
+++ b/bridge/src/ai/providers/anthropic.provider.ts
@@ -0,0 +1,65 @@
+import Anthropic from "@anthropic-ai/sdk";
+import { AIProvider, classifyError } from "./types";
+import {
+ SchemaAnalysisInput,
+ QueryExplanationInput,
+ ChartRecommendationInput,
+ ChartRecommendation,
+} from "../../types";
+import { buildSchemaAnalysisPrompt } from "../prompts/schema-analysis";
+import { buildQueryExplanationPrompt } from "../prompts/query-explanation";
+import { buildChartRecommendationPrompt, parseChartRecommendation } from "../prompts/chart-recommendation";
+
+const DEFAULT_MODEL = "claude-3-5-haiku-20241022";
+
+export class AnthropicProvider implements AIProvider {
+ private client: Anthropic;
+
+ constructor(apiKey: string) {
+ this.client = new Anthropic({ apiKey });
+ }
+
+ private async complete(system: string, user: string): Promise {
+ try {
+ const msg = await this.client.messages.create({
+ model: DEFAULT_MODEL,
+ max_tokens: 4096,
+ system,
+ messages: [{ role: "user", content: user }],
+ });
+ const block = msg.content[0];
+ return block.type === "text" ? block.text : "";
+ } catch (err) {
+ throw classifyError(err, "anthropic");
+ }
+ }
+
+ async analyzeSchema(input: SchemaAnalysisInput): Promise {
+ const { system, user } = buildSchemaAnalysisPrompt(input);
+ return this.complete(system, user);
+ }
+
+ async explainQuery(input: QueryExplanationInput): Promise {
+ const { system, user } = buildQueryExplanationPrompt(input);
+ return this.complete(system, user);
+ }
+
+ async recommendChart(input: ChartRecommendationInput): Promise {
+ const { system, user } = buildChartRecommendationPrompt(input);
+ const raw = await this.complete(system, user);
+ return parseChartRecommendation(raw);
+ }
+
+ async testConnection(): Promise {
+ try {
+ await this.client.messages.create({
+ model: DEFAULT_MODEL,
+ max_tokens: 10,
+ messages: [{ role: "user", content: "ping" }],
+ });
+ return "";
+ } catch (err) {
+ throw classifyError(err, "anthropic");
+ }
+ }
+}
diff --git a/bridge/src/ai/providers/gemini.provider.ts b/bridge/src/ai/providers/gemini.provider.ts
new file mode 100644
index 0000000..8de2bc8
--- /dev/null
+++ b/bridge/src/ai/providers/gemini.provider.ts
@@ -0,0 +1,61 @@
+import { GoogleGenerativeAI } from "@google/generative-ai";
+import { AIProvider, classifyError } from "./types";
+import {
+ SchemaAnalysisInput,
+ QueryExplanationInput,
+ ChartRecommendationInput,
+ ChartRecommendation,
+} from "../../types/";
+import { buildSchemaAnalysisPrompt } from "../prompts/schema-analysis";
+import { buildQueryExplanationPrompt } from "../prompts/query-explanation";
+import { buildChartRecommendationPrompt, parseChartRecommendation } from "../prompts/chart-recommendation";
+
+const DEFAULT_MODEL = "gemini-1.5-flash";
+
+export class GeminiProvider implements AIProvider {
+ private genAI: GoogleGenerativeAI;
+
+ constructor(apiKey: string) {
+ this.genAI = new GoogleGenerativeAI(apiKey);
+ }
+
+ private async complete(system: string, user: string): Promise {
+ try {
+ const model = this.genAI.getGenerativeModel({
+ model: DEFAULT_MODEL,
+ systemInstruction: system,
+ generationConfig: { maxOutputTokens: 4096 },
+ });
+ const result = await model.generateContent(user);
+ return result.response.text();
+ } catch (err) {
+ throw classifyError(err, "gemini");
+ }
+ }
+
+ async analyzeSchema(input: SchemaAnalysisInput): Promise {
+ const { system, user } = buildSchemaAnalysisPrompt(input);
+ return this.complete(system, user);
+ }
+
+ async explainQuery(input: QueryExplanationInput): Promise {
+ const { system, user } = buildQueryExplanationPrompt(input);
+ return this.complete(system, user);
+ }
+
+ async recommendChart(input: ChartRecommendationInput): Promise {
+ const { system, user } = buildChartRecommendationPrompt(input);
+ const raw = await this.complete(system, user);
+ return parseChartRecommendation(raw);
+ }
+
+ async testConnection(): Promise {
+ try {
+ const model = this.genAI.getGenerativeModel({ model: DEFAULT_MODEL });
+ await model.generateContent("ping");
+ return "";
+ } catch (err) {
+ throw classifyError(err, "gemini");
+ }
+ }
+}
diff --git a/bridge/src/ai/providers/groq.provider.ts b/bridge/src/ai/providers/groq.provider.ts
new file mode 100644
index 0000000..4b840d7
--- /dev/null
+++ b/bridge/src/ai/providers/groq.provider.ts
@@ -0,0 +1,66 @@
+import Groq from "groq-sdk";
+import { AIProvider, classifyError } from "./types";
+import {
+ SchemaAnalysisInput,
+ QueryExplanationInput,
+ ChartRecommendationInput,
+ ChartRecommendation,
+} from "../../types/";
+import { buildSchemaAnalysisPrompt } from "../prompts/schema-analysis";
+import { buildQueryExplanationPrompt } from "../prompts/query-explanation";
+import { buildChartRecommendationPrompt, parseChartRecommendation } from "../prompts/chart-recommendation";
+
+const DEFAULT_MODEL = "llama-3.3-70b-versatile";
+
+export class GroqProvider implements AIProvider {
+ private client: Groq;
+
+ constructor(apiKey: string) {
+ this.client = new Groq({ apiKey });
+ }
+
+ private async complete(system: string, user: string): Promise {
+ try {
+ const res = await this.client.chat.completions.create({
+ model: DEFAULT_MODEL,
+ messages: [
+ { role: "system", content: system },
+ { role: "user", content: user },
+ ],
+ max_tokens: 4096,
+ });
+ return res.choices[0]?.message?.content ?? "";
+ } catch (err) {
+ throw classifyError(err, "groq");
+ }
+ }
+
+ async analyzeSchema(input: SchemaAnalysisInput): Promise {
+ const { system, user } = buildSchemaAnalysisPrompt(input);
+ return this.complete(system, user);
+ }
+
+ async explainQuery(input: QueryExplanationInput): Promise {
+ const { system, user } = buildQueryExplanationPrompt(input);
+ return this.complete(system, user);
+ }
+
+ async recommendChart(input: ChartRecommendationInput): Promise {
+ const { system, user } = buildChartRecommendationPrompt(input);
+ const raw = await this.complete(system, user);
+ return parseChartRecommendation(raw);
+ }
+
+ async testConnection(): Promise {
+ try {
+ await this.client.chat.completions.create({
+ model: DEFAULT_MODEL,
+ messages: [{ role: "user", content: "ping" }],
+ max_tokens: 5,
+ });
+ return "";
+ } catch (err) {
+ throw classifyError(err, "groq");
+ }
+ }
+}
diff --git a/bridge/src/ai/providers/mistral.provider.ts b/bridge/src/ai/providers/mistral.provider.ts
new file mode 100644
index 0000000..abb74ee
--- /dev/null
+++ b/bridge/src/ai/providers/mistral.provider.ts
@@ -0,0 +1,72 @@
+import { Mistral } from "@mistralai/mistralai";
+import { AIProvider, classifyError } from "./types";
+import {
+ SchemaAnalysisInput,
+ QueryExplanationInput,
+ ChartRecommendationInput,
+ ChartRecommendation,
+} from "../../types/";
+import { buildSchemaAnalysisPrompt } from "../prompts/schema-analysis";
+import { buildQueryExplanationPrompt } from "../prompts/query-explanation";
+import { buildChartRecommendationPrompt, parseChartRecommendation } from "../prompts/chart-recommendation";
+
+const DEFAULT_MODEL = "mistral-small-latest";
+
+export class MistralProvider implements AIProvider {
+ private client: Mistral;
+
+ constructor(apiKey: string) {
+ this.client = new Mistral({ apiKey });
+ }
+
+ private async complete(system: string, user: string): Promise {
+ try {
+ const res = await this.client.chat.complete({
+ model: DEFAULT_MODEL,
+ messages: [
+ { role: "system", content: system },
+ { role: "user", content: user },
+ ],
+ maxTokens: 4096,
+ });
+ const choice = res.choices?.[0];
+ const content = choice?.message?.content;
+ if (typeof content === "string") return content;
+ if (Array.isArray(content)) {
+ return content.map((c: any) => c.text ?? "").join("");
+ }
+ return "";
+ } catch (err) {
+ throw classifyError(err, "mistral");
+ }
+ }
+
+ async analyzeSchema(input: SchemaAnalysisInput): Promise {
+ const { system, user } = buildSchemaAnalysisPrompt(input);
+ return this.complete(system, user);
+ }
+
+ async explainQuery(input: QueryExplanationInput): Promise {
+ const { system, user } = buildQueryExplanationPrompt(input);
+ return this.complete(system, user);
+ }
+
+ async recommendChart(input: ChartRecommendationInput): Promise {
+ const { system, user } = buildChartRecommendationPrompt(input);
+ const raw = await this.complete(system, user);
+ return parseChartRecommendation(raw);
+ }
+
+ async testConnection(): Promise {
+ try {
+ await this.client.chat.complete({
+ model: DEFAULT_MODEL,
+ messages: [{ role: "user", content: "ping" }],
+ maxTokens: 5,
+ });
+ return "";
+ } catch (err) {
+ throw classifyError(err, "mistral");
+ }
+ }
+}
diff --git a/bridge/src/ai/providers/ollama.provider.ts b/bridge/src/ai/providers/ollama.provider.ts
new file mode 100644
index 0000000..5452c04
--- /dev/null
+++ b/bridge/src/ai/providers/ollama.provider.ts
@@ -0,0 +1,69 @@
+import { Ollama } from "ollama";
+import { AIProvider, classifyError } from "./types";
+import {
+ SchemaAnalysisInput,
+ QueryExplanationInput,
+ ChartRecommendationInput,
+ ChartRecommendation,
+} from "../../types/";
+import { buildSchemaAnalysisPrompt } from "../prompts/schema-analysis";
+import { buildQueryExplanationPrompt } from "../prompts/query-explanation";
+import { buildChartRecommendationPrompt, parseChartRecommendation } from "../prompts/chart-recommendation";
+
+const DEFAULT_MODEL = "llama3.2";
+
+export class OllamaProvider implements AIProvider {
+ private client: Ollama;
+ private model: string;
+
+ constructor(baseUrl?: string, model?: string) {
+ this.client = new Ollama({ host: baseUrl ?? "http://localhost:11434" });
+ this.model = model?.trim() || DEFAULT_MODEL;
+ }
+
+ private async complete(system: string, user: string): Promise {
+ try {
+ const res = await this.client.chat({
+ model: this.model,
+ messages: [
+ { role: "system", content: system },
+ { role: "user", content: user },
+ ],
+ options: { num_predict: 4096 },
+ });
+ return res.message?.content ?? "";
+ } catch (err) {
+ throw classifyError(err, "ollama");
+ }
+ }
+
+ async analyzeSchema(input: SchemaAnalysisInput): Promise {
+ const { system, user } = buildSchemaAnalysisPrompt(input);
+ return this.complete(system, user);
+ }
+
+ async explainQuery(input: QueryExplanationInput): Promise {
+ const { system, user } = buildQueryExplanationPrompt(input);
+ return this.complete(system, user);
+ }
+
+ async recommendChart(input: ChartRecommendationInput): Promise {
+ const { system, user } = buildChartRecommendationPrompt(input);
+ const raw = await this.complete(system, user);
+ return parseChartRecommendation(raw);
+ }
+
+ async testConnection(): Promise {
+ try {
+ // List models to verify Ollama is reachable and the model exists
+ const list = await this.client.list();
+ const available = list.models.map((m: any) => m.name);
+ if (!available.some((n: string) => n.startsWith(this.model.split(":")[0]))) {
+ throw new Error(`Model "${this.model}" not found. Available: ${available.join(", ") || "none"}`);
+ }
+ return "";
+ } catch (err) {
+ throw classifyError(err, "ollama");
+ }
+ }
+}
diff --git a/bridge/src/ai/providers/openai.provider.ts b/bridge/src/ai/providers/openai.provider.ts
new file mode 100644
index 0000000..1c9e841
--- /dev/null
+++ b/bridge/src/ai/providers/openai.provider.ts
@@ -0,0 +1,66 @@
+import OpenAI from "openai";
+import { AIProvider, classifyError } from "./types";
+import {
+ SchemaAnalysisInput,
+ QueryExplanationInput,
+ ChartRecommendationInput,
+ ChartRecommendation,
+} from "../../types";
+import { buildSchemaAnalysisPrompt } from "../prompts/schema-analysis";
+import { buildQueryExplanationPrompt } from "../prompts/query-explanation";
+import { buildChartRecommendationPrompt, parseChartRecommendation } from "../prompts/chart-recommendation";
+
+const DEFAULT_MODEL = "gpt-4o-mini";
+
+export class OpenAIProvider implements AIProvider {
+ private client: OpenAI;
+
+ constructor(apiKey: string) {
+ this.client = new OpenAI({ apiKey });
+ }
+
+ private async complete(system: string, user: string): Promise {
+ try {
+ const res = await this.client.chat.completions.create({
+ model: DEFAULT_MODEL,
+ messages: [
+ { role: "system", content: system },
+ { role: "user", content: user },
+ ],
+ max_tokens: 4096,
+ });
+ return res.choices[0]?.message?.content ?? "";
+ } catch (err) {
+ throw classifyError(err, "openai");
+ }
+ }
+
+ async analyzeSchema(input: SchemaAnalysisInput): Promise {
+ const { system, user } = buildSchemaAnalysisPrompt(input);
+ return this.complete(system, user);
+ }
+
+ async explainQuery(input: QueryExplanationInput): Promise {
+ const { system, user } = buildQueryExplanationPrompt(input);
+ return this.complete(system, user);
+ }
+
+ async recommendChart(input: ChartRecommendationInput): Promise {
+ const { system, user } = buildChartRecommendationPrompt(input);
+ const raw = await this.complete(system, user);
+ return parseChartRecommendation(raw);
+ }
+
+ async testConnection(): Promise {
+ try {
+ await this.client.chat.completions.create({
+ model: DEFAULT_MODEL,
+ messages: [{ role: "user", content: "ping" }],
+ max_tokens: 5,
+ });
+ return "";
+ } catch (err) {
+ throw classifyError(err, "openai");
+ }
+ }
+}
diff --git a/bridge/src/ai/providers/types.ts b/bridge/src/ai/providers/types.ts
new file mode 100644
index 0000000..2d4bdb4
--- /dev/null
+++ b/bridge/src/ai/providers/types.ts
@@ -0,0 +1,85 @@
+import {
+ SchemaAnalysisInput,
+ QueryExplanationInput,
+ ChartRecommendationInput,
+ ChartRecommendation,
+} from "../../types/ai";
+
+// ── Provider interface ────────────────────────────────────────────────────
+
+/**
+ * All AI providers must implement this interface.
+ * Frontend code never knows which concrete provider is active.
+ */
+export interface AIProvider {
+ /** Analyze a database schema and return a markdown report. */
+ analyzeSchema(input: SchemaAnalysisInput): Promise;
+
+ /** Explain a SQL query in plain language, returning markdown. */
+ explainQuery(input: QueryExplanationInput): Promise;
+
+ /** Recommend a chart type + axes for the given table. */
+ recommendChart(input: ChartRecommendationInput): Promise;
+
+ /**
+ * Verify that the provider is reachable with the supplied credentials.
+ * Resolves with an empty string on success, a user-facing message on failure.
+ */
+ testConnection(): Promise;
+}
+
+// ── Standardized error type ───────────────────────────────────────────────
+
+export type AIErrorCode =
+ | "MISSING_API_KEY"
+ | "INVALID_API_KEY"
+ | "PROVIDER_UNAVAILABLE"
+ | "INVALID_MODEL"
+ | "NETWORK_FAILURE"
+ | "TIMEOUT"
+ | "RATE_LIMIT"
+ | "PARSE_ERROR"
+ | "UNKNOWN";
+
+export class AIError extends Error {
+ readonly code: AIErrorCode;
+ readonly provider: string;
+ readonly originalMessage: string;
+
+ constructor(code: AIErrorCode, provider: string, message: string) {
+ super(`[${provider}] ${message}`);
+ this.name = "AIError";
+ this.code = code;
+ this.provider = provider;
+ this.originalMessage = message;
+ }
+}
+
+/**
+ * Classify a raw SDK/network error into an AIErrorCode.
+ */
+export function classifyError(err: unknown, provider: string): AIError {
+ const msg = err instanceof Error ? err.message : String(err);
+ const lower = msg.toLowerCase();
+
+ if (lower.includes("api key") || lower.includes("apikey") || lower.includes("authentication") || lower.includes("unauthorized") || lower.includes("401")) {
+ return new AIError("INVALID_API_KEY", provider, msg);
+ }
+ if (lower.includes("rate limit") || lower.includes("429") || lower.includes("too many requests")) {
+ return new AIError("RATE_LIMIT", provider, msg);
+ }
+ if (lower.includes("timeout") || lower.includes("timed out")) {
+ return new AIError("TIMEOUT", provider, msg);
+ }
+ if (lower.includes("econnrefused") || lower.includes("enotfound") || lower.includes("fetch failed") || lower.includes("network")) {
+ return new AIError("NETWORK_FAILURE", provider, msg);
+ }
+ if (lower.includes("model") && (lower.includes("not found") || lower.includes("invalid"))) {
+ return new AIError("INVALID_MODEL", provider, msg);
+ }
+ if (lower.includes("unavailable") || lower.includes("503") || lower.includes("overloaded")) {
+ return new AIError("PROVIDER_UNAVAILABLE", provider, msg);
+ }
+
+ return new AIError("UNKNOWN", provider, msg);
+}
diff --git a/bridge/src/connectors/README.md b/bridge/src/connectors/README.md
new file mode 100644
index 0000000..a94891f
--- /dev/null
+++ b/bridge/src/connectors/README.md
@@ -0,0 +1,26 @@
+# Database Connectors (Bridge)
+
+This folder contains database-specific connector implementations used by the bridge to execute metadata, query, migration and CRUD operations.
+
+Layout
+- `postgres.ts` - PostgreSQL connector, cache manager, metadata helpers, streaming query support and PostgreSQL-specific DDL/DML behavior.
+- `mysql.ts` - MySQL connector for schema/table metadata, stats, migrations and query execution.
+- `mariadb.ts` - MariaDB connector. Keep MariaDB behavior separate when it diverges from MySQL.
+- `sqlite.ts` - SQLite connector using `better-sqlite3`, local file handling and SQLite-specific migration/query behavior.
+
+How it fits
+- `src/services/queryExecutor.ts` routes bridge operations to the correct connector based on `DBType`.
+- SQL strings should come from `src/queries/*` where practical, not be duplicated inline.
+- Shared result shapes come from `src/types/common.ts`; database-specific config and metadata types live in `src/types/postgres.ts`, `src/types/mysql.ts` and `src/types/sqlite.ts`.
+- Connection config objects are built by `src/services/connectionBuilder.ts`; connectors generally create their own driver client from that config for each operation.
+
+How to add connector behavior
+1. Add database-specific SQL to `src/queries//` when the operation needs raw SQL.
+2. Add or update typed method support in the relevant connector file.
+3. Route the operation from `QueryExecutor` or the owning service.
+4. Register any new public RPC method through a handler in `src/handlers`.
+
+Notes
+- Keep connector methods focused on database interaction. Do not read UI payloads directly or handle RPC responses here.
+- Invalidate or bypass connector caches when an operation mutates schema, table data or migration state.
+- Prefer shared common types unless a database really needs extra metadata fields.
diff --git a/bridge/src/connectors/mariadb.ts b/bridge/src/connectors/mariadb.ts
index 8ce9151..6c77d95 100644
--- a/bridge/src/connectors/mariadb.ts
+++ b/bridge/src/connectors/mariadb.ts
@@ -7,7 +7,8 @@ import mysql, {
import { loadLocalMigrations, writeBaselineMigration } from "../utils/baselineMigration";
import crypto from "crypto";
import fs from "fs";
-import { ensureDir, getMigrationsDir } from "../utils/config";
+import { ensureDir } from "../utils/config";
+import { projectStoreInstance } from "../services/projectStore";
import {
CacheEntry,
CACHE_TTL,
@@ -34,6 +35,7 @@ import {
MySQLAlterTableOperation as MariaDBAlterTableOperation,
MySQLDropMode as MariaDBDropMode,
} from "../types/mysql";
+import { SchemaFile } from "../services/projectStore";
export type {
ColumnDetail,
@@ -548,7 +550,7 @@ export function streamQueryCancelable(
conn = await pool.getConnection();
const [pidRows] = await conn.execute(GET_CONNECTION_ID);
- backendPid = pidRows[0].pid;
+ backendPid = (pidRows as any[])[0].pid;
const raw = (conn as any).connection;
query = raw.query(sql);
@@ -916,7 +918,12 @@ export async function getSchemaMetadataBatch(
not_nullable: Boolean(row.not_nullable),
default_value: row.default_value,
is_primary_key: Boolean(row.is_primary_key),
- is_foreign_key: Boolean(row.is_foreign_key)
+ is_foreign_key: Boolean(row.is_foreign_key),
+ is_unique: Boolean(row.is_unique),
+ is_serial: Boolean(row.is_serial),
+ comment: row.comment,
+ check_constraint: row.check_constraint,
+ ordinal_position: row.ordinal_position
});
}
@@ -1349,10 +1356,10 @@ export async function insertBaseline(
await connection.query(INSERT_MIGRATION, [version, name, checksum]);
}
-
export async function baselineIfNeeded(
conn: MariaDBConfig,
- migrationsDir: string
+ migrationsDir: string,
+ snapshot?: SchemaFile
) {
try {
await ensureMigrationTable(conn);
@@ -1363,10 +1370,22 @@ export async function baselineIfNeeded(
const version = Date.now().toString();
const name = "baseline_existing_schema";
+ const fakeSnapshot = snapshot || {
+ version: 2,
+ projectId: "",
+ databaseId: "",
+ dialect: "mysql",
+ schemas: [],
+ cachedAt: "",
+ relwaveVersion: "",
+ schemaHash: ""
+ };
+
const filePath = writeBaselineMigration(
migrationsDir,
version,
- name
+ name,
+ fakeSnapshot
);
const checksum = crypto
@@ -1420,11 +1439,19 @@ export async function connectToDatabase(
options?: { readOnly?: boolean }
) {
let baselineResult = { baselined: false };
- const migrationsDir = getMigrationsDir(connectionId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(connectionId);
ensureDir(migrationsDir);
// 1️⃣ Baseline (ONLY if not read-only)
if (!options?.readOnly) {
- baselineResult = await baselineIfNeeded(cfg, migrationsDir);
+ // Pass real schema snapshot so baseline contains actual DDL
+ let snapshot: any = undefined;
+ try {
+ const project = await projectStoreInstance.getProjectByDatabaseId(connectionId);
+ if (project) {
+ snapshot = await projectStoreInstance.getSchema(project.id) || undefined;
+ }
+ } catch { }
+ baselineResult = await baselineIfNeeded(cfg, migrationsDir, snapshot);
}
// 2️⃣ Load schema (read-only introspection)
diff --git a/bridge/src/connectors/mysql.ts b/bridge/src/connectors/mysql.ts
index 72acba9..dd49a42 100644
--- a/bridge/src/connectors/mysql.ts
+++ b/bridge/src/connectors/mysql.ts
@@ -7,7 +7,8 @@ import mysql, {
import { loadLocalMigrations, writeBaselineMigration } from "../utils/baselineMigration";
import crypto from "crypto";
import fs from "fs";
-import { ensureDir, getMigrationsDir } from "../utils/config";
+import { ensureDir } from "../utils/config";
+import { projectStoreInstance } from "../services/projectStore";
import {
CacheEntry,
CACHE_TTL,
@@ -34,6 +35,7 @@ import {
MySQLAlterTableOperation,
MySQLDropMode,
} from "../types/mysql";
+import { SchemaFile } from "../services/projectStore";
// Re-export types for backward compatibility
export type {
@@ -523,7 +525,7 @@ export function streamQueryCancelable(
conn = await pool.getConnection();
const [pidRows] = await conn.execute(GET_CONNECTION_ID);
- backendPid = pidRows[0].pid;
+ backendPid = (pidRows as any[])[0].pid;
const raw = (conn as any).connection;
query = raw.query(sql);
@@ -892,7 +894,12 @@ export async function getSchemaMetadataBatch(
not_nullable: Boolean(row.not_nullable),
default_value: row.default_value,
is_primary_key: Boolean(row.is_primary_key),
- is_foreign_key: Boolean(row.is_foreign_key)
+ is_foreign_key: Boolean(row.is_foreign_key),
+ is_unique: Boolean(row.is_unique),
+ is_serial: Boolean(row.is_serial),
+ comment: row.comment,
+ check_constraint: row.check_constraint,
+ ordinal_position: row.ordinal_position
});
}
@@ -1103,7 +1110,7 @@ function groupMySQLIndexes(indexes: IndexInfo[]) {
}
return [...map.values()].map(group =>
- group.sort((a, b) => a.seq_in_index - b.seq_in_index)
+ group.sort((a, b) => (a.seq_in_index ?? 0) - (b.seq_in_index ?? 0))
);
}
@@ -1324,10 +1331,10 @@ export async function insertBaseline(
await connection.query(INSERT_MIGRATION, [version, name, checksum]);
}
-
export async function baselineIfNeeded(
conn: MySQLConfig,
- migrationsDir: string
+ migrationsDir: string,
+ snapshot?: SchemaFile
) {
try {
await ensureMigrationTable(conn);
@@ -1338,10 +1345,22 @@ export async function baselineIfNeeded(
const version = Date.now().toString();
const name = "baseline_existing_schema";
+ const fakeSnapshot = snapshot || {
+ version: 2,
+ projectId: "",
+ databaseId: "",
+ dialect: "mysql",
+ schemas: [],
+ cachedAt: "",
+ relwaveVersion: "",
+ schemaHash: ""
+ };
+
const filePath = writeBaselineMigration(
migrationsDir,
version,
- name
+ name,
+ fakeSnapshot
);
const checksum = crypto
@@ -1395,11 +1414,19 @@ export async function connectToDatabase(
options?: { readOnly?: boolean }
) {
let baselineResult = { baselined: false };
- const migrationsDir = getMigrationsDir(connectionId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(connectionId);
ensureDir(migrationsDir);
// 1️⃣ Baseline (ONLY if not read-only)
if (!options?.readOnly) {
- baselineResult = await baselineIfNeeded(cfg, migrationsDir);
+ // Pass real schema snapshot so baseline contains actual DDL
+ let snapshot: any = undefined;
+ try {
+ const project = await projectStoreInstance.getProjectByDatabaseId(connectionId);
+ if (project) {
+ snapshot = await projectStoreInstance.getSchema(project.id) || undefined;
+ }
+ } catch {}
+ baselineResult = await baselineIfNeeded(cfg, migrationsDir, snapshot);
}
// 2️⃣ Load schema (read-only introspection)
diff --git a/bridge/src/connectors/postgres.ts b/bridge/src/connectors/postgres.ts
index 19084dc..4f56d94 100644
--- a/bridge/src/connectors/postgres.ts
+++ b/bridge/src/connectors/postgres.ts
@@ -5,7 +5,8 @@ import { Readable } from "stream";
import { loadLocalMigrations, writeBaselineMigration } from "../utils/baselineMigration";
import crypto from "crypto";
import fs from "fs";
-import { ensureDir, getMigrationsDir } from "../utils/config";
+import { ensureDir } from "../utils/config";
+import { projectStoreInstance } from "../services/projectStore";
import {
CacheEntry,
CACHE_TTL,
@@ -32,6 +33,7 @@ import {
PGAlterTableOperation,
PGDropMode,
} from "../types/postgres";
+import { SchemaFile } from "../services/projectStore";
export type {
PGConfig,
@@ -801,7 +803,12 @@ export async function getSchemaMetadataBatch(
not_nullable: row.not_nullable,
default_value: row.default_value,
is_primary_key: row.is_primary_key,
- is_foreign_key: row.is_foreign_key
+ is_foreign_key: row.is_foreign_key,
+ is_unique: row.is_unique,
+ is_serial: row.is_serial,
+ check_constraint: row.check_constraint,
+ comment: row.comment,
+ ordinal_position: row.ordinal_position
});
}
@@ -1169,7 +1176,7 @@ function groupIndexes(indexes: IndexInfo[]) {
}
return [...map.values()].map(group =>
- group.sort((a, b) => a.ordinal_position - b.ordinal_position)
+ group.sort((a, b) => (a.ordinal_position ?? 0) - (b.ordinal_position ?? 0))
);
}
@@ -1409,10 +1416,10 @@ export async function insertBaseline(
}
}
-
export async function baselineIfNeeded(
conn: PGConfig,
- migrationsDir: string
+ migrationsDir: string,
+ snapshot?: SchemaFile
) {
const client = createClient(conn);
@@ -1426,10 +1433,22 @@ export async function baselineIfNeeded(
const version = Date.now().toString();
const name = "baseline_existing_schema";
+ const fakeSnapshot = snapshot || {
+ version: 2,
+ projectId: "",
+ databaseId: "",
+ dialect: "postgresql",
+ schemas: [],
+ cachedAt: "",
+ relwaveVersion: "",
+ schemaHash: ""
+ };
+
const filePath = writeBaselineMigration(
migrationsDir,
version,
- name
+ name,
+ fakeSnapshot
);
const checksum = crypto
@@ -1485,11 +1504,19 @@ export async function connectToDatabase(
) {
// 1️⃣ Baseline (only if allowed)
let baselineResult = { baselined: false };
- const migrationsDir = getMigrationsDir(connectionId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(connectionId);
ensureDir(migrationsDir);
if (!options?.readOnly) {
- baselineResult = await baselineIfNeeded(cfg, migrationsDir);
+ // Pass real schema snapshot so baseline contains actual DDL
+ let snapshot: any = undefined;
+ try {
+ const project = await projectStoreInstance.getProjectByDatabaseId(connectionId);
+ if (project) {
+ snapshot = await projectStoreInstance.getSchema(project.id) || undefined;
+ }
+ } catch { }
+ baselineResult = await baselineIfNeeded(cfg, migrationsDir, snapshot);
}
// 2️⃣ Load schema (read-only)
diff --git a/bridge/src/connectors/sqlite.ts b/bridge/src/connectors/sqlite.ts
index 0815fb0..38b56e4 100644
--- a/bridge/src/connectors/sqlite.ts
+++ b/bridge/src/connectors/sqlite.ts
@@ -5,7 +5,8 @@ import crypto from "crypto";
import fs from "fs";
import os from "os";
import path from "path";
-import { ensureDir, getMigrationsDir } from "../utils/config";
+import { ensureDir } from "../utils/config";
+import { projectStoreInstance } from "../services/projectStore";
import { isWindowsDriveRootPath, normalizeSQLitePath } from "../utils/sqlitePath";
import {
CacheEntry,
@@ -55,6 +56,7 @@ import {
} from "../queries/sqlite/migrations";
import { SQLITE_GET_TABLE_SQL } from "../queries/sqlite/constraints";
import { sqliteQuoteIdentifier } from "../queries/sqlite/crud";
+import { SchemaFile } from "../services/projectStore";
// ============================================
// CACHING SYSTEM FOR SQLITE CONNECTOR
@@ -322,7 +324,7 @@ function getInstalledBindingCandidates(execDir: string): string[] {
return Array.from(searchRoots, (root) => path.join(root, "better_sqlite3.node"));
}
-function resolvePkgNativeBindingPath(): string | undefined {
+export function resolvePkgNativeBindingPath(): string | undefined {
if (cachedNativeBindingPath !== undefined) {
return cachedNativeBindingPath ?? undefined;
}
@@ -376,7 +378,9 @@ function resolvePkgNativeBindingPath(): string | undefined {
const snapshotRoot = path.join(path.sep, "snapshot", "bridge", "node_modules");
snapshotCandidates.push(
path.join(snapshotRoot, "better-sqlite3", "build", "Release", "better_sqlite3.node"),
- path.join(snapshotRoot, ".pnpm", "better-sqlite3@12.6.2", "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node")
+ path.join(snapshotRoot, ".pnpm", "better-sqlite3@12.6.2", "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node"),
+ path.join(snapshotRoot, ".pnpm", "better-sqlite3@11.10.0", "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node"),
+ path.join(snapshotRoot, ".pnpm", "better-sqlite3@11.9.0", "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node")
);
const targetNodeFile = path.join(os.tmpdir(), `relwave-better_sqlite3-${process.pid}.node`);
@@ -732,6 +736,11 @@ export async function getSchemaMetadataBatch(
default_value: col.dflt_value,
is_primary_key: col.pk > 0,
is_foreign_key: fkColumns.has(col.name),
+ is_unique: false, // will be updated below if unique index exists
+ is_serial: col.pk > 0 && (col.type || '').toLowerCase() === 'integer',
+ check_constraint: undefined,
+ comment: undefined,
+ ordinal_position: col.cid + 1,
}));
const primaryKeys: PrimaryKeyInfo[] = cols
@@ -769,14 +778,19 @@ export async function getSchemaMetadataBatch(
ordinal_position: col.seqno,
});
- if (idx.unique === 1 && idx.origin !== 'pk') {
- uniqueConstraints.push({
- constraint_name: idx.name,
- table_schema: 'main',
- table_name: tableName,
- column_name: col.name,
- ordinal_position: col.seqno,
- });
+ if (idx.unique === 1) {
+ const c = columns.find(c => c.name === col.name);
+ if (c) c.is_unique = true;
+
+ if (idx.origin !== 'pk') {
+ uniqueConstraints.push({
+ constraint_name: idx.name,
+ table_schema: 'main',
+ table_name: tableName,
+ column_name: col.name,
+ ordinal_position: col.seqno,
+ });
+ }
}
}
}
@@ -1097,7 +1111,8 @@ export async function insertBaseline(
/** Baseline if needed */
export async function baselineIfNeeded(
cfg: SQLiteConfig,
- migrationsDir: string
+ migrationsDir: string,
+ snapshot?: SchemaFile
) {
await ensureMigrationTable(cfg);
@@ -1107,7 +1122,18 @@ export async function baselineIfNeeded(
const version = Date.now().toString();
const name = "baseline_existing_schema";
- const filePath = writeBaselineMigration(migrationsDir, version, name);
+ const fakeSnapshot = snapshot || {
+ version: 2,
+ projectId: "",
+ databaseId: "",
+ dialect: "sqlite",
+ schemas: [],
+ cachedAt: "",
+ relwaveVersion: "",
+ schemaHash: ""
+ };
+
+ const filePath = writeBaselineMigration(migrationsDir, version, name, fakeSnapshot);
const checksum = crypto
.createHash("sha256")
@@ -1142,11 +1168,19 @@ export async function connectToDatabase(
options?: { readOnly?: boolean }
) {
let baselineResult = { baselined: false };
- const migrationsDir = getMigrationsDir(connectionId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(connectionId);
ensureDir(migrationsDir);
if (!options?.readOnly) {
- baselineResult = await baselineIfNeeded(cfg, migrationsDir);
+ // Pass real schema snapshot so baseline contains actual DDL
+ let snapshot: any = undefined;
+ try {
+ const project = await projectStoreInstance.getProjectByDatabaseId(connectionId);
+ if (project) {
+ snapshot = await projectStoreInstance.getSchema(project.id) || undefined;
+ }
+ } catch {}
+ baselineResult = await baselineIfNeeded(cfg, migrationsDir, snapshot);
}
const schema = await listSchemas(cfg);
diff --git a/bridge/src/handlers/README.md b/bridge/src/handlers/README.md
new file mode 100644
index 0000000..110354b
--- /dev/null
+++ b/bridge/src/handlers/README.md
@@ -0,0 +1,33 @@
+# RPC Handlers (Bridge)
+
+This folder contains JSON-RPC handler classes. Handlers translate frontend RPC calls into service/query work and send normalized RPC responses or errors.
+
+Layout
+- `databaseHandlers.ts` - database connection CRUD, connection tests and schema/table metadata entry points.
+- `queryHandlers.ts` - query execution, table browsing and table/row mutation handlers.
+- `sessionHandlers.ts` - query session lifecycle and cancellation.
+- `statsHandlers.ts` - database and aggregate statistics.
+- `migrationHandlers.ts` - migration generation, application, rollback, deletion and SQL retrieval.
+- `projectHandlers.ts` - project persistence, schema snapshots, ER diagrams, annotations, saved queries and import/export.
+- `gitHandlers.ts` - core local Git actions.
+- `gitAdvancedHandlers.ts` - remotes, push/pull/fetch and revert operations.
+- `monitoringHandlers.ts` - database monitoring snapshot and websocket info endpoints.
+- `aiHandlers.ts` - AI provider tests, schema analysis, query explanation, chart recommendation and history.
+
+How it fits
+- `src/jsonRpcHandler.ts` constructs handlers and registers public method names such as `db.list`, `query.run`, `project.create`, `git.status` and `ai.analyzeSchema`.
+- Handlers depend on services like `DatabaseService`, `QueryExecutor`, `MonitoringService` and `GitService`.
+- Handlers own RPC validation and error mapping. Services and connectors should throw normal errors rather than calling `rpc.sendResponse` directly.
+
+How to add an RPC method
+1. Add a handler method in the correct handler class.
+2. Validate required params near the top of the handler.
+3. Call service/query logic and return `{ ok: true, data }` through `rpc.sendResponse`.
+4. Convert failures to `rpc.sendError(id, { code, message })`.
+5. Register the method in `src/jsonRpcHandler.ts`.
+6. Add or update the matching frontend bridge service in `src/services/bridge`.
+
+Notes
+- Keep handler classes thin. If logic grows beyond request validation and orchestration, move it into `src/services`.
+- Avoid leaking credentials in responses; strip or omit password and credential identifiers before responding.
+- Use stable error codes because the frontend may surface them in notifications.
diff --git a/bridge/src/handlers/aiHandlers.ts b/bridge/src/handlers/aiHandlers.ts
new file mode 100644
index 0000000..49201b4
--- /dev/null
+++ b/bridge/src/handlers/aiHandlers.ts
@@ -0,0 +1,222 @@
+import { Logger } from "pino";
+import { Rpc } from "../types";
+import { AIService } from "../services/aiService";
+import { AIError } from "../ai/providers/types";
+import {
+ AIAnalyzeSchemaParams,
+ AIExplainQueryParams,
+ AIRecommendChartParams,
+ AITestConnectionParams,
+} from "../types/ai";
+import {
+ getOrCall,
+ hashSchemaAnalysis,
+ hashQueryExplanation,
+ hashChartRecommendation,
+} from "../services/aiCacheService";
+import { aiHistoryStore } from "../services/aiHistoryStore";
+import { buildSchemaAnalysisPrompt } from "../ai/prompts/schema-analysis";
+import { buildQueryExplanationPrompt } from "../ai/prompts/query-explanation";
+import { buildChartRecommendationPrompt } from "../ai/prompts/chart-recommendation";
+import { parseChartRecommendation } from "../ai/prompts/chart-recommendation";
+
+export class AIHandlers {
+ private aiService: AIService;
+
+ constructor(private rpc: Rpc, private logger: Logger) {
+ this.aiService = new AIService();
+ }
+
+ async handleTestConnection(params: AITestConnectionParams, id: number | string) {
+ try {
+ const provider = this.aiService.resolveProvider(params.settings);
+ await provider.testConnection();
+ this.rpc.sendResponse(id, { ok: true, data: { connected: true } });
+ } catch (err) {
+ this.logger?.warn({ err }, "ai.testConnection failed");
+ const msg = err instanceof AIError ? err.originalMessage : String(err);
+ const code = err instanceof AIError ? err.code : "UNKNOWN";
+ this.rpc.sendError(id, { code, message: msg });
+ }
+ }
+
+ async handleAnalyzeSchema(params: AIAnalyzeSchemaParams & { skipCache?: boolean }, id: number | string) {
+ try {
+ if (!params?.input?.tables?.length) {
+ return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "No tables provided." });
+ }
+
+ const hash = hashSchemaAnalysis(params.input, params.datasourceName);
+ const { system, user } = buildSchemaAnalysisPrompt(params.input);
+ const prompt = `${system}\n\n${user}`;
+
+ const result = await getOrCall({
+ feature: "schema-analysis",
+ hash,
+ settings: params.settings,
+ prompt,
+ callFn: async () => {
+ const provider = this.aiService.resolveProvider(params.settings);
+ return provider.analyzeSchema(params.input);
+ },
+ meta: { datasource_id: params.datasourceName, table_name: params.tableName },
+ skipCache: params.skipCache,
+ });
+
+ this.rpc.sendResponse(id, {
+ ok: true,
+ data: {
+ markdown: result.response,
+ cached: result.cached,
+ createdAt: result.createdAt,
+ },
+ });
+ } catch (err) {
+ this.logger?.error({ err }, "ai.analyzeSchema failed");
+ const msg = err instanceof AIError ? err.originalMessage : String(err);
+ const code = err instanceof AIError ? err.code : "UNKNOWN";
+ this.rpc.sendError(id, { code, message: msg });
+ }
+ }
+
+ async handleExplainQuery(params: AIExplainQueryParams & { skipCache?: boolean }, id: number | string) {
+ try {
+ if (!params?.input?.sql?.trim()) {
+ return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "No SQL provided." });
+ }
+
+ const hash = hashQueryExplanation(params.input, params.datasourceName);
+ const { system, user } = buildQueryExplanationPrompt(params.input);
+ const prompt = `${system}\n\n${user}`;
+
+ const result = await getOrCall({
+ feature: "query-explanation",
+ hash,
+ settings: params.settings,
+ prompt,
+ callFn: async () => {
+ const provider = this.aiService.resolveProvider(params.settings);
+ return provider.explainQuery(params.input);
+ },
+ meta: { datasource_id: params.datasourceName, table_name: params.tableName },
+ skipCache: params.skipCache,
+ });
+
+ this.rpc.sendResponse(id, {
+ ok: true,
+ data: {
+ markdown: result.response,
+ cached: result.cached,
+ createdAt: result.createdAt,
+ },
+ });
+ } catch (err) {
+ this.logger?.error({ err }, "ai.explainQuery failed");
+ const msg = err instanceof AIError ? err.originalMessage : String(err);
+ const code = err instanceof AIError ? err.code : "UNKNOWN";
+ this.rpc.sendError(id, { code, message: msg });
+ }
+ }
+
+ async handleRecommendChart(params: AIRecommendChartParams & { skipCache?: boolean }, id: number | string) {
+ try {
+ if (!params?.input?.tableName || !params?.input?.columns?.length) {
+ return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing tableName or columns." });
+ }
+
+ const hash = hashChartRecommendation(params.input, params.datasourceName);
+ const { system, user } = buildChartRecommendationPrompt(params.input);
+ const prompt = `${system}\n\n${user}`;
+
+ const result = await getOrCall({
+ feature: "chart-recommendation",
+ hash,
+ settings: params.settings,
+ prompt,
+ callFn: async () => {
+ const provider = this.aiService.resolveProvider(params.settings);
+ const rec = await provider.recommendChart(params.input);
+ // Serialize the recommendation as JSON so it can be stored as a string
+ return JSON.stringify(rec);
+ },
+ meta: {
+ datasource_id: params.datasourceName,
+ table_name: params.tableName ?? params.input.tableName,
+ },
+ skipCache: params.skipCache,
+ });
+
+ // Parse the stored JSON response back into a ChartRecommendation
+ let recommendation;
+ try {
+ recommendation = JSON.parse(result.response);
+ } catch {
+ recommendation = parseChartRecommendation(result.response);
+ }
+
+ this.rpc.sendResponse(id, {
+ ok: true,
+ data: {
+ ...recommendation,
+ cached: result.cached,
+ createdAt: result.createdAt,
+ },
+ });
+ } catch (err) {
+ this.logger?.error({ err }, "ai.recommendChart failed");
+ const msg = err instanceof AIError ? err.originalMessage : String(err);
+ const code = err instanceof AIError ? err.code : "UNKNOWN";
+ this.rpc.sendError(id, { code, message: msg });
+ }
+ }
+
+ // ── History CRUD handlers ─────────────────────────────────────────────
+
+ async handleGetHistory(params: { feature?: string; provider?: string; limit?: number; offset?: number }, id: number | string) {
+ try {
+ const result = aiHistoryStore.list({
+ feature: params?.feature,
+ provider: params?.provider,
+ limit: params?.limit,
+ offset: params?.offset,
+ });
+ this.rpc.sendResponse(id, { ok: true, data: result });
+ } catch (err: any) {
+ this.logger?.error({ err }, "ai.getHistory failed");
+ this.rpc.sendError(id, { code: "HISTORY_ERROR", message: err?.message ?? String(err) });
+ }
+ }
+
+ async handleGetHistoryById(params: { id: number }, id: number | string) {
+ try {
+ const record = aiHistoryStore.getById(params.id);
+ if (!record) {
+ return this.rpc.sendError(id, { code: "NOT_FOUND", message: "History entry not found." });
+ }
+ this.rpc.sendResponse(id, { ok: true, data: record });
+ } catch (err: any) {
+ this.logger?.error({ err }, "ai.getHistoryById failed");
+ this.rpc.sendError(id, { code: "HISTORY_ERROR", message: err?.message ?? String(err) });
+ }
+ }
+
+ async handleDeleteHistory(params: { id: number }, id: number | string) {
+ try {
+ const deleted = aiHistoryStore.deleteById(params.id);
+ this.rpc.sendResponse(id, { ok: true, data: { deleted } });
+ } catch (err: any) {
+ this.logger?.error({ err }, "ai.deleteHistory failed");
+ this.rpc.sendError(id, { code: "HISTORY_ERROR", message: err?.message ?? String(err) });
+ }
+ }
+
+ async handleClearHistory(_params: unknown, id: number | string) {
+ try {
+ const count = aiHistoryStore.clearAll();
+ this.rpc.sendResponse(id, { ok: true, data: { deletedCount: count } });
+ } catch (err: any) {
+ this.logger?.error({ err }, "ai.clearHistory failed");
+ this.rpc.sendError(id, { code: "HISTORY_ERROR", message: err?.message ?? String(err) });
+ }
+ }
+}
diff --git a/bridge/src/handlers/migrationHandlers.ts b/bridge/src/handlers/migrationHandlers.ts
index f4cefd8..9cbdcbb 100644
--- a/bridge/src/handlers/migrationHandlers.ts
+++ b/bridge/src/handlers/migrationHandlers.ts
@@ -2,9 +2,9 @@ import { Rpc } from "../types";
import { DatabaseService } from "../services/databaseService";
import { QueryExecutor } from "../services/queryExecutor";
import { Logger } from "pino";
-import { getMigrationsDir } from "../utils/config";
import path from "path";
import fs from "fs";
+import { projectStoreInstance } from "../services/projectStore";
export class MigrationHandlers {
constructor(
@@ -26,7 +26,7 @@ export class MigrationHandlers {
}
const { conn, dbType } = await this.dbService.getDatabaseConnection(dbId);
- const migrationsDir = getMigrationsDir(dbId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
// Generate migration file
const { generateCreateTableMigration, writeMigrationFile } = await import('../utils/migrationGenerator');
@@ -66,7 +66,7 @@ export class MigrationHandlers {
}
const { conn, dbType } = await this.dbService.getDatabaseConnection(dbId);
- const migrationsDir = getMigrationsDir(dbId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
// Generate migration file
const { generateAlterTableMigration, writeMigrationFile } = await import('../utils/migrationGenerator');
@@ -105,7 +105,7 @@ export class MigrationHandlers {
}
const { conn, dbType } = await this.dbService.getDatabaseConnection(dbId);
- const migrationsDir = getMigrationsDir(dbId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
// Generate migration file
const { generateDropTableMigration, writeMigrationFile } = await import('../utils/migrationGenerator');
@@ -144,7 +144,7 @@ export class MigrationHandlers {
}
const { conn, dbType } = await this.dbService.getDatabaseConnection(dbId);
- const migrationsDir = getMigrationsDir(dbId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
// Find migration file
const { listMigrationFiles } = await import('../utils/migrationFileReader');
@@ -160,7 +160,6 @@ export class MigrationHandlers {
const migrationFilePath = path.join(migrationsDir, migrationFile);
- // Apply migration
if (dbType === "mysql") {
await this.queryExecutor.mysql.applyMigration(conn, migrationFilePath);
} else if (dbType === "postgres") {
@@ -171,6 +170,44 @@ export class MigrationHandlers {
await this.queryExecutor.sqlite.applyMigration(conn, migrationFilePath);
}
+ // Hook syncMigrationFiles
+ try {
+ const { projectStoreInstance } = await import("../services/projectStore");
+ const { gitServiceInstance } = await import("../services/gitService");
+ const { writeMigrationLock } = await import("../services/migrationLock");
+
+ const project = await projectStoreInstance.getProjectByDatabaseId(dbId);
+ if (project) {
+ // Update Migration Lock first so it gets included in the git sync commit
+ let appliedMigrations: any[] = [];
+ if (dbType === "mysql") {
+ appliedMigrations = await require("../connectors/mysql").listAppliedMigrations(conn);
+ } else if (dbType === "postgres") {
+ appliedMigrations = await require("../connectors/postgres").listAppliedMigrations(conn);
+ } else if (dbType === "mariadb") {
+ appliedMigrations = await require("../connectors/mariadb").listAppliedMigrations(conn);
+ } else if (dbType === "sqlite") {
+ appliedMigrations = await require("../connectors/sqlite").listAppliedMigrations(conn);
+ }
+
+ const schemaFile = await projectStoreInstance.getSchema(project.id);
+ const schemaHash = (schemaFile as any)?.schemaHash || "";
+
+ const appliedVersions = appliedMigrations.map(m => m.version);
+ await writeMigrationLock(dbId, schemaHash, appliedVersions);
+
+ const projectDir = await projectStoreInstance.resolveProjectDir(project.id);
+ if (projectDir) {
+ const syncResult = await gitServiceInstance.syncMigrationFiles(projectDir);
+ if (syncResult.error) {
+ this.logger?.warn({ error: syncResult.error }, "Git sync failed after migration apply");
+ }
+ }
+ }
+ } catch (syncErr) {
+ this.logger?.error({ err: syncErr }, "syncMigrationFiles/lock hook failed");
+ }
+
this.rpc.sendResponse(id, { ok: true });
} catch (e: any) {
this.logger?.error({ e }, "migration.apply failed");
@@ -178,6 +215,132 @@ export class MigrationHandlers {
}
}
+ async handleApplyMigrations(params: any, id: number | string) {
+ try {
+ let { dbId, projectId } = params || {};
+
+ // Resolve dbId from projectId if not directly provided
+ if (!dbId && projectId) {
+ const project = await projectStoreInstance.getProject(projectId);
+ if (!project?.databaseId) {
+ return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Project has no linked database" });
+ }
+ dbId = project.databaseId;
+ }
+ if (!dbId) return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing dbId or projectId" });
+
+ const { conn, dbType } = await this.dbService.getDatabaseConnection(dbId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
+
+ const { loadLocalMigrations } = await import('../utils/baselineMigration');
+ const localMigrations = await loadLocalMigrations(migrationsDir);
+
+ let connector: any;
+ if (dbType === "mysql") connector = require("../connectors/mysql");
+ else if (dbType === "postgres") connector = require("../connectors/postgres");
+ else if (dbType === "mariadb") connector = require("../connectors/mariadb");
+ else if (dbType === "sqlite") connector = require("../connectors/sqlite");
+
+ const appliedMigrations = await connector.listAppliedMigrations(conn);
+ const appliedSet = new Set(appliedMigrations.map((m: any) => m.version));
+
+ const pending = localMigrations.filter(m => !appliedSet.has(m.version)).sort((a, b) => a.version.localeCompare(b.version));
+
+ for (const migration of pending) {
+ const migrationFile = (await fs.promises.readdir(migrationsDir)).find(f => f.startsWith(migration.version));
+ if (!migrationFile) throw new Error(`Migration file not found for version: ${migration.version}`);
+
+ const migrationFilePath = path.join(migrationsDir, migrationFile);
+ if (dbType === "mysql") await this.queryExecutor.mysql.applyMigration(conn, migrationFilePath);
+ else if (dbType === "postgres") await this.queryExecutor.postgres.applyMigration(conn, migrationFilePath);
+ else if (dbType === "mariadb") await this.queryExecutor.mariadb.applyMigration(conn, migrationFilePath);
+ else if (dbType === "sqlite") await this.queryExecutor.sqlite.applyMigration(conn, migrationFilePath);
+ }
+
+ try {
+ const { projectStoreInstance } = await import("../services/projectStore");
+ const { writeMigrationLock } = await import("../services/migrationLock");
+ const project = await projectStoreInstance.getProjectByDatabaseId(dbId);
+ if (project) {
+ const schemaFile = await projectStoreInstance.getSchema(project.id);
+ const schemaHash = (schemaFile as any)?.schemaHash || "";
+ const updatedApplied = await connector.listAppliedMigrations(conn);
+ await writeMigrationLock(dbId, schemaHash, updatedApplied.map((m: any) => m.version));
+ }
+ } catch (syncErr) {
+ this.logger?.error({ err: syncErr }, "Lock hook failed");
+ }
+
+ this.rpc.sendResponse(id, { ok: true, count: pending.length });
+ } catch (e: any) {
+ this.logger?.error({ e }, "migration.applyMigrations failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
+ async handleApplySnapshot(params: any, id: number | string) {
+ try {
+ let { dbId, projectId } = params || {};
+ const { projectStoreInstance } = await import("../services/projectStore");
+
+ // Resolve dbId from projectId if not directly provided
+ if (!dbId && projectId) {
+ const project = await projectStoreInstance.getProject(projectId);
+ if (!project?.databaseId) {
+ return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Project has no linked database" });
+ }
+ dbId = project.databaseId;
+ }
+ if (!dbId) return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing dbId or projectId" });
+
+ const { conn, dbType } = await this.dbService.getDatabaseConnection(dbId);
+
+ const project = await projectStoreInstance.getProjectByDatabaseId(dbId);
+ if (!project) throw new Error("Project not found for database");
+ const snapshot = await projectStoreInstance.getSchema(project.id);
+ if (!snapshot) throw new Error("Schema snapshot not found");
+
+ const { generateBaselineSQL } = await import("../utils/baselineMigration");
+ const baselineSQL = generateBaselineSQL(snapshot, Date.now().toString(), "apply_snapshot");
+
+ let connector: any;
+ if (dbType === "mysql") connector = require("../connectors/mysql");
+ else if (dbType === "postgres") connector = require("../connectors/postgres");
+ else if (dbType === "mariadb") connector = require("../connectors/mariadb");
+ else if (dbType === "sqlite") connector = require("../connectors/sqlite");
+
+ // For full snapshot apply, we assume DB is empty or we should drop it.
+ // A full implementation would drop tables here.
+ // For now, we write the SQL and apply it.
+
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
+ const versionStr = Date.now().toString();
+ const baselinePath = path.join(migrationsDir, `${versionStr}_apply_snapshot.sql`);
+ fs.writeFileSync(baselinePath, baselineSQL, "utf8");
+
+ if (dbType === "mysql") await this.queryExecutor.mysql.applyMigration(conn, baselinePath);
+ else if (dbType === "postgres") await this.queryExecutor.postgres.applyMigration(conn, baselinePath);
+ else if (dbType === "mariadb") await this.queryExecutor.mariadb.applyMigration(conn, baselinePath);
+ else if (dbType === "sqlite") await this.queryExecutor.sqlite.applyMigration(conn, baselinePath);
+
+ if (fs.existsSync(baselinePath)) fs.unlinkSync(baselinePath);
+
+ try {
+ const { writeMigrationLock } = await import("../services/migrationLock");
+ const schemaHash = (snapshot as any)?.schemaHash || "";
+ const updatedApplied = await connector.listAppliedMigrations(conn);
+ await writeMigrationLock(dbId, schemaHash, updatedApplied.map((m: any) => m.version));
+ } catch (syncErr) {
+ this.logger?.error({ err: syncErr }, "Lock hook failed");
+ }
+
+ this.rpc.sendResponse(id, { ok: true });
+ } catch (e: any) {
+ this.logger?.error({ e }, "migration.applySnapshot failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
async handleRollbackMigration(params: any, id: number | string) {
try {
const { dbId, version } = params || {};
@@ -190,7 +353,7 @@ export class MigrationHandlers {
}
const { conn, dbType } = await this.dbService.getDatabaseConnection(dbId);
- const migrationsDir = getMigrationsDir(dbId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
// Find migration file
const { listMigrationFiles } = await import('../utils/migrationFileReader');
@@ -217,6 +380,44 @@ export class MigrationHandlers {
await this.queryExecutor.sqlite.rollbackMigration(conn, version, migrationFilePath);
}
+ // Hook syncMigrationFiles and Lock Update
+ try {
+ const { projectStoreInstance } = await import("../services/projectStore");
+ const { gitServiceInstance } = await import("../services/gitService");
+ const { writeMigrationLock } = await import("../services/migrationLock");
+
+ const project = await projectStoreInstance.getProjectByDatabaseId(dbId);
+ if (project) {
+ // Update Migration Lock first so it gets included in the git sync commit
+ let appliedMigrations: any[] = [];
+ if (dbType === "mysql") {
+ appliedMigrations = await require("../connectors/mysql").listAppliedMigrations(conn);
+ } else if (dbType === "postgres") {
+ appliedMigrations = await require("../connectors/postgres").listAppliedMigrations(conn);
+ } else if (dbType === "mariadb") {
+ appliedMigrations = await require("../connectors/mariadb").listAppliedMigrations(conn);
+ } else if (dbType === "sqlite") {
+ appliedMigrations = await require("../connectors/sqlite").listAppliedMigrations(conn);
+ }
+
+ const schemaFile = await projectStoreInstance.getSchema(project.id);
+ const schemaHash = (schemaFile as any)?.schemaHash || "";
+
+ const appliedVersions = appliedMigrations.map(m => m.version);
+ await writeMigrationLock(dbId, schemaHash, appliedVersions);
+
+ const projectDir = await projectStoreInstance.resolveProjectDir(project.id);
+ if (projectDir) {
+ const syncResult = await gitServiceInstance.syncMigrationFiles(projectDir);
+ if (syncResult.error) {
+ this.logger?.warn({ error: syncResult.error }, "Git sync failed after migration rollback");
+ }
+ }
+ }
+ } catch (syncErr) {
+ this.logger?.error({ err: syncErr }, "syncMigrationFiles/lock hook failed");
+ }
+
this.rpc.sendResponse(id, { ok: true });
} catch (e: any) {
this.logger?.error({ e }, "migration.rollback failed");
@@ -235,7 +436,7 @@ export class MigrationHandlers {
});
}
- const migrationsDir = getMigrationsDir(dbId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
// Find and delete migration file
const { listMigrationFiles } = await import('../utils/migrationFileReader');
@@ -270,7 +471,7 @@ export class MigrationHandlers {
});
}
- const migrationsDir = getMigrationsDir(dbId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
// Find and read migration file
const { listMigrationFiles, readMigrationFile } = await import('../utils/migrationFileReader');
diff --git a/bridge/src/handlers/projectHandlers.ts b/bridge/src/handlers/projectHandlers.ts
index d9f3f22..c62600c 100644
--- a/bridge/src/handlers/projectHandlers.ts
+++ b/bridge/src/handlers/projectHandlers.ts
@@ -1,6 +1,10 @@
import { Rpc } from "../types";
import { Logger } from "pino";
import { projectStoreInstance } from "../services/projectStore";
+import { DatabaseService } from "../services/databaseService";
+import { QueryExecutor } from "../services/queryExecutor";
+import { gitServiceInstance } from "../services/gitService";
+import path from "path";
/**
* RPC handlers for project CRUD and sub-resource operations.
@@ -9,7 +13,9 @@ import { projectStoreInstance } from "../services/projectStore";
export class ProjectHandlers {
constructor(
private rpc: Rpc,
- private logger: Logger
+ private logger: Logger,
+ private dbService: DatabaseService,
+ private queryExecutor: QueryExecutor
) { }
@@ -170,6 +176,67 @@ export class ProjectHandlers {
}
}
+ async handleRefreshSchemaCache(params: any, id: number | string) {
+ try {
+ const { projectId } = params || {};
+ if (!projectId) {
+ return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" });
+ }
+
+ const project = await projectStoreInstance.getProject(projectId);
+ if (!project) {
+ return this.rpc.sendError(id, { code: "NOT_FOUND", message: "Project not found" });
+ }
+
+ if (!project.databaseId) {
+ return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Project has no database ID" });
+ }
+
+ // Get DB connection
+ const { conn, dbType } = await this.dbService.getDatabaseConnection(project.databaseId);
+ const dbSchema = await this.queryExecutor.listSchemas(conn, dbType) as any;
+ const liveSchemas = dbSchema.schemas;
+
+ // Generate schema hash from live schemas
+ const crypto = require("crypto");
+ const newHash = crypto.createHash("sha256").update(JSON.stringify(liveSchemas)).digest("hex");
+
+ // Fetch old schema file to compare
+ let oldHash = "";
+ try {
+ const oldSchemaFile = await projectStoreInstance.getSchema(projectId);
+ oldHash = (oldSchemaFile as any)?.schemaHash || "";
+ } catch (e) {
+ // Initial creation
+ }
+
+ // Save anyway (or only if changed, but we can always save)
+ // projectStoreInstance.saveSchema now expects to write schemaHash and dialect.
+ // Let's call saveSchema and then update the schema.json directly or update saveSchema signature.
+ // Since saveSchema only takes `schemas` right now, I'll update saveSchema in projectStore shortly.
+ await projectStoreInstance.saveSchema(projectId, liveSchemas, newHash, dbType);
+
+ if (newHash !== oldHash) {
+ this.rpc?.sendNotification?.("project.schema_changed", { projectId, newHash });
+
+ // Commit to Git if tracking
+ try {
+ const projectDir = await projectStoreInstance.resolveProjectDir(projectId);
+ if (projectDir) {
+ await gitServiceInstance.syncSchemaFile(projectDir);
+ }
+ } catch (gitErr) {
+ this.logger?.warn({ err: gitErr }, "Failed to auto-commit schema.json to Git");
+ }
+ }
+
+ this.rpc.sendResponse(id, { ok: true, hashChanged: newHash !== oldHash, newHash });
+ } catch (e: any) {
+ this.logger?.error({ e }, "project.refreshSchemaCache failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
async handleGetERDiagram(params: any, id: number | string) {
try {
const { projectId } = params || {};
@@ -247,6 +314,263 @@ export class ProjectHandlers {
}
}
+ async handleAnalyzeImport(params: any, id: number | string) {
+ try {
+ const { projectId } = params || {};
+ if (!projectId) {
+ return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" });
+ }
+
+ const project = await projectStoreInstance.getProject(projectId);
+ if (!project) {
+ return this.rpc.sendError(id, { code: "NOT_FOUND", message: "Project not found" });
+ }
+
+ // 1. Read schema snapshot
+ const schemaFile = await projectStoreInstance.getSchema(projectId);
+ const hasSchemaSnapshot = !!schemaFile && !!(schemaFile as any)?.schemas?.length;
+ const schemaHash = (schemaFile as any)?.schemaHash || "";
+
+ // 2. Read migration files from project-local dir
+ const { listMigrationFiles, readMigrationFile } = await import("../utils/migrationFileReader");
+ let migrationFiles: string[] = [];
+ let migrationsDir = "";
+ if (project.databaseId) {
+ migrationsDir = await projectStoreInstance.resolveMigrationsDir(project.databaseId);
+ migrationFiles = listMigrationFiles(migrationsDir);
+ }
+ const hasMigrations = migrationFiles.length > 0;
+
+ // 3. Read lock file
+ let lockFileStatus: "valid" | "tampered" | "missing" = "missing";
+ let tamperedFiles: string[] = [];
+ let appliedVersions: string[] = [];
+ if (project.databaseId) {
+ const { readMigrationLock } = await import("../services/migrationLock");
+ const lock = await readMigrationLock(project.databaseId);
+ if (lock) {
+ lockFileStatus = lock.schemaHash === schemaHash ? "valid" : "tampered";
+ appliedVersions = lock.appliedMigrations || [];
+ }
+ }
+
+ // 4. Query live database for table count
+ let targetDatabaseEmpty = true;
+ let targetTableCount = 0;
+ let dbAppliedVersions: string[] = [];
+ if (project.databaseId) {
+ try {
+ const { conn, dbType } = await this.dbService.getDatabaseConnection(project.databaseId);
+ let connector: any;
+ if (dbType === "mysql") connector = require("../connectors/mysql");
+ else if (dbType === "postgres") connector = require("../connectors/postgres");
+ else if (dbType === "mariadb") connector = require("../connectors/mariadb");
+ else if (dbType === "sqlite") connector = require("../connectors/sqlite");
+
+ if (connector) {
+ try {
+ const tables = await connector.listTables(conn);
+ // Filter out internal migration tracking tables
+ const userTables = (tables || []).filter((t: any) => {
+ const name = typeof t === "string" ? t : t?.name || "";
+ return name !== "__relwave_migrations" && name !== "relwave_migrations";
+ });
+ targetTableCount = userTables.length;
+ targetDatabaseEmpty = targetTableCount === 0;
+ } catch {
+ // Can't list tables — assume non-empty for safety
+ targetDatabaseEmpty = false;
+ }
+
+ try {
+ const applied = await connector.listAppliedMigrations(conn);
+ dbAppliedVersions = (applied || []).map((m: any) => m.version);
+ } catch {
+ // Migration tracking table may not exist yet
+ }
+ }
+ } catch {
+ // Database connection may not be available
+ }
+ }
+
+ // 5. Compute pending migrations
+ const appliedSet = new Set(dbAppliedVersions);
+ const pendingMigrations: Array<{
+ file: string;
+ version: string;
+ isDestructive: boolean;
+ destructiveOps: string[];
+ }> = [];
+
+ for (const file of migrationFiles) {
+ const versionMatch = file.match(/^(\d{13,14})/);
+ if (!versionMatch) continue;
+ const version = versionMatch[1];
+ if (appliedSet.has(version)) continue;
+
+ // Parse for destructive operations
+ let isDestructive = false;
+ const destructiveOps: string[] = [];
+ try {
+ const parsed = readMigrationFile(path.join(migrationsDir, file));
+ const upSQL = parsed.upSQL.toUpperCase();
+ if (upSQL.includes("DROP TABLE")) {
+ isDestructive = true;
+ destructiveOps.push("DROP TABLE");
+ }
+ if (upSQL.includes("DROP COLUMN")) {
+ isDestructive = true;
+ destructiveOps.push("DROP COLUMN");
+ }
+ if (upSQL.includes("TRUNCATE")) {
+ isDestructive = true;
+ destructiveOps.push("TRUNCATE");
+ }
+ } catch {
+ // Skip parse errors
+ }
+
+ pendingMigrations.push({ file, version, isDestructive, destructiveOps });
+ }
+
+ // 6. Detect if all migration files are baseline-only (no real user migrations)
+ const isBaselineOnly = hasMigrations && migrationFiles.every(f => f.includes("baseline"));
+
+ // 7. Compute drift status
+ let driftStatus: "synced" | "drifted" | "unknown" = "unknown";
+ if (pendingMigrations.length === 0 && hasMigrations) {
+ // All migrations applied
+ driftStatus = "synced";
+ } else if (pendingMigrations.length === 0 && !hasMigrations && !hasSchemaSnapshot) {
+ // No migrations or schema — fresh project
+ driftStatus = "synced";
+ } else if (pendingMigrations.length > 0) {
+ driftStatus = "drifted";
+ } else if (!hasMigrations && hasSchemaSnapshot && targetDatabaseEmpty) {
+ // Schema exists but database is empty — need to apply snapshot
+ driftStatus = "drifted";
+ } else if (!hasMigrations && hasSchemaSnapshot && !targetDatabaseEmpty) {
+ // Schema exists and database has tables — assume synced (user just hasn't made migrations yet)
+ driftStatus = "synced";
+ }
+
+ // 8. Compute available modes
+ const availableModes: Array<"run_migrations" | "apply_snapshot" | "skip"> = ["skip"];
+ let recommendedMode: "run_migrations" | "apply_snapshot" | "skip" = "skip";
+
+ if (isBaselineOnly && hasSchemaSnapshot && targetDatabaseEmpty) {
+ // Baseline-only + empty DB + schema.json exists:
+ // The baseline file has no real DDL — use schema.json to reconstruct the DB
+ availableModes.unshift("apply_snapshot");
+ recommendedMode = "apply_snapshot";
+ // Override: treat as "no real migrations" for the dialog
+ driftStatus = "drifted";
+ } else if (hasMigrations && pendingMigrations.length > 0 && !isBaselineOnly) {
+ availableModes.unshift("run_migrations");
+ recommendedMode = "run_migrations";
+ }
+ if (hasSchemaSnapshot && targetDatabaseEmpty && !hasMigrations) {
+ availableModes.unshift("apply_snapshot");
+ recommendedMode = "apply_snapshot";
+ }
+
+ // For the dialog: if baseline-only + empty DB, report as "no real migrations"
+ // so the frontend shows "Apply Schema Snapshot" (STATE 3) instead of
+ // "Pending Migrations" (STATE 2) which would try to run the empty baseline
+ const reportHasMigrations = isBaselineOnly && targetDatabaseEmpty ? false : hasMigrations;
+ const reportPendingMigrations = isBaselineOnly && targetDatabaseEmpty ? [] : pendingMigrations;
+
+ const result = {
+ hasMigrations: reportHasMigrations,
+ migrationCount: reportPendingMigrations.length,
+ hasSchemaSnapshot,
+ lockFileStatus,
+ tamperedFiles,
+ targetDatabaseEmpty,
+ targetTableCount,
+ driftStatus,
+ driftDetails: undefined,
+ pendingMigrations: reportPendingMigrations,
+ availableModes,
+ recommendedMode,
+ };
+
+ this.rpc.sendResponse(id, { ok: true, data: result });
+ } catch (e: any) {
+ this.logger?.error({ e }, "project.analyzeImport failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
+ async handleVerifyLock(params: any, id: number | string) {
+ try {
+ const { projectId } = params || {};
+ if (!projectId) {
+ return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" });
+ }
+ const project = await projectStoreInstance.getProject(projectId);
+ if (!project?.databaseId) {
+ return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Project has no database ID" });
+ }
+ const { verifyMigrationLock } = await import("../services/migrationLock");
+ const schemaFile = await projectStoreInstance.getSchema(projectId);
+ const schemaHash = (schemaFile as any)?.schemaHash || "";
+
+ const isValid = await verifyMigrationLock(project.databaseId, schemaHash);
+ this.rpc.sendResponse(id, { ok: true, isValid });
+ } catch (e: any) {
+ this.logger?.error({ e }, "project.verifyLock failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
+ async handlePushMigrations(params: any, id: number | string) {
+ try {
+ const { projectId } = params || {};
+ if (!projectId) return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" });
+ const projectDir = await projectStoreInstance.resolveProjectDir(projectId);
+ if (!projectDir) return this.rpc.sendError(id, { code: "NOT_FOUND", message: "Project directory not found" });
+
+ const { gitServiceInstance } = await import("../services/gitService");
+ await gitServiceInstance.pushMigrations(projectDir);
+ this.rpc.sendResponse(id, { ok: true });
+ } catch (e: any) {
+ this.logger?.error({ e }, "project.pushMigrations failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
+ async handleSyncMigrations(params: any, id: number | string) {
+ try {
+ const { projectId } = params || {};
+ if (!projectId) return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" });
+ const projectDir = await projectStoreInstance.resolveProjectDir(projectId);
+ if (!projectDir) return this.rpc.sendError(id, { code: "NOT_FOUND", message: "Project directory not found" });
+
+ const { gitServiceInstance } = await import("../services/gitService");
+ const result = await gitServiceInstance.syncMigrationFiles(projectDir);
+ if (result.error) throw new Error(result.error.message || String(result.error));
+ this.rpc.sendResponse(id, { ok: true, data: result });
+ } catch (e: any) {
+ this.logger?.error({ e }, "project.syncMigrations failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
+ async handleGetDrift(params: any, id: number | string) {
+ try {
+ const { projectId } = params || {};
+ if (!projectId) return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" });
+
+ // Stub implementation for now
+ this.rpc.sendResponse(id, { ok: true, data: { driftDetected: false, differences: [] } });
+ } catch (e: any) {
+ this.logger?.error({ e }, "project.getDrift failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
async handleGetQueries(params: any, id: number | string) {
try {
const { projectId } = params || {};
@@ -430,7 +754,7 @@ export class ProjectHandlers {
this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
}
}
-
+
/** Read-only scan — returns metadata + .env info without creating anything. */
async handleScanImport(params: any, id: number | string) {
try {
@@ -503,4 +827,115 @@ export class ProjectHandlers {
this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
}
}
+
+ async handleUnlinkFromConnection(params: any, id: number | string) {
+ try {
+ const { databaseId } = params || {};
+ if (!databaseId) {
+ return this.rpc.sendError(id, {
+ code: "BAD_REQUEST",
+ message: "Missing databaseId",
+ });
+ }
+
+ await projectStoreInstance.unlinkDatabase(databaseId);
+ this.rpc.sendResponse(id, { ok: true });
+ } catch (e: any) {
+ this.logger?.error({ e }, "project.unlinkFromConnection failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
+ async handleDeleteWithConnection(params: any, id: number | string) {
+ try {
+ const { databaseId } = params || {};
+ if (!databaseId) {
+ return this.rpc.sendError(id, {
+ code: "BAD_REQUEST",
+ message: "Missing databaseId",
+ });
+ }
+
+ await projectStoreInstance.deleteProjectByDatabaseId(databaseId);
+ this.rpc.sendResponse(id, { ok: true });
+ } catch (e: any) {
+ this.logger?.error({ e }, "project.deleteWithConnection failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
+ async handleGetGitRemote(params: any, id: number | string) {
+ try {
+ const { projectPath } = params || {};
+ if (!projectPath) {
+ return this.rpc.sendError(id, {
+ code: "BAD_REQUEST",
+ message: "Missing projectPath",
+ });
+ }
+
+ // Using gitServiceInstance since it handles standard git operations
+ const remotes = await gitServiceInstance.remoteList(projectPath);
+ const remoteUrl = remotes.length > 0 ? remotes[0].fetchUrl : null;
+
+ this.rpc.sendResponse(id, { ok: true, data: { remoteUrl } });
+ } catch (e: any) {
+ // If it's not a git repo or fails, just return null
+ this.logger?.error({ e }, "project.getGitRemote failed (might not be a git repo)");
+ this.rpc.sendResponse(id, { ok: true, data: { remoteUrl: null } });
+ }
+ }
+
+ async handleRelinkToConnection(params: any, id: number | string) {
+ try {
+ const { projectId, databaseId } = params || {};
+ if (!projectId || !databaseId) {
+ return this.rpc.sendError(id, {
+ code: "BAD_REQUEST",
+ message: "Missing projectId or databaseId",
+ });
+ }
+
+ const project = await projectStoreInstance.relinkDatabase(projectId, databaseId);
+
+ this.rpc.sendResponse(id, { ok: true, data: project });
+ } catch (e: any) {
+ this.logger?.error({ e }, "project.relinkToConnection failed");
+ // Check if it's the specific error we throw
+ if (e.message?.includes("DATABASE_ALREADY_HAS_PROJECT")) {
+ this.rpc.sendError(id, { code: "DATABASE_ALREADY_HAS_PROJECT", message: e.message });
+ } else {
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+ }
+
+ async handleGenerateSQL(params: any, id: number | string) {
+ try {
+ const { projectId } = params || {};
+ if (!projectId) {
+ return this.rpc.sendError(id, {
+ code: "BAD_REQUEST",
+ message: "Missing projectId",
+ });
+ }
+
+ const { projectStoreInstance } = await import("../services/projectStore");
+ const schemaFile = await projectStoreInstance.getSchema(projectId);
+ if (!schemaFile) {
+ return this.rpc.sendError(id, {
+ code: "NOT_FOUND",
+ message: "No schema snapshot found",
+ });
+ }
+
+ const { generateBaselineSQL } = await import("../utils/baselineMigration");
+ const sql = generateBaselineSQL(schemaFile as any, "preview", "preview");
+
+ this.rpc.sendResponse(id, { ok: true, data: { sql } });
+ } catch (e: any) {
+ this.logger?.error({ e }, "project.generateSQL failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
}
diff --git a/bridge/src/jsonRpcHandler.ts b/bridge/src/jsonRpcHandler.ts
index 8b90f6c..e6604c5 100644
--- a/bridge/src/jsonRpcHandler.ts
+++ b/bridge/src/jsonRpcHandler.ts
@@ -65,10 +65,11 @@ export function registerDbHandlers(
dbService,
queryExecutor
);
- const projectHandlers = new ProjectHandlers(rpc, logger);
+ const projectHandlers = new ProjectHandlers(rpc, logger, dbService, queryExecutor);
const gitHandlers = new GitHandlers(rpc, logger);
const gitAdvancedHandlers = new GitAdvancedHandlers(rpc, logger);
const monitoringHandlers = new MonitoringHandlers(rpc, logger, dbService, monitoringService);
+ const aiHandlers = new (require("./handlers/aiHandlers")).AIHandlers(rpc, logger);
// ==========================================
// SESSION MANAGEMENT HANDLERS
@@ -167,6 +168,12 @@ export function registerDbHandlers(
rpcRegister(rpc, "migration.apply", (p, id) =>
migrationHandlers.handleApplyMigration(p, id)
);
+ rpcRegister(rpc, "migration.applyMigrations", (p, id) =>
+ migrationHandlers.handleApplyMigrations(p, id)
+ );
+ rpcRegister(rpc, "migration.applySnapshot", (p, id) =>
+ migrationHandlers.handleApplySnapshot(p, id)
+ );
rpcRegister(rpc, "migration.rollback", (p, id) =>
migrationHandlers.handleRollbackMigration(p, id)
);
@@ -226,6 +233,9 @@ export function registerDbHandlers(
rpcRegister(rpc, "project.saveSchema", (p, id) =>
projectHandlers.handleSaveSchema(p, id)
);
+ rpcRegister(rpc, "project.refreshSchemaCache", (p, id) =>
+ projectHandlers.handleRefreshSchemaCache(p, id)
+ );
rpcRegister(rpc, "project.getERDiagram", (p, id) =>
projectHandlers.handleGetERDiagram(p, id)
);
@@ -238,6 +248,24 @@ export function registerDbHandlers(
rpcRegister(rpc, "project.saveAnnotations", (p, id) =>
projectHandlers.handleSaveAnnotations(p, id)
);
+ rpcRegister(rpc, "project.analyzeImport", (p, id) =>
+ projectHandlers.handleAnalyzeImport(p, id)
+ );
+ rpcRegister(rpc, "project.verifyLock", (p, id) =>
+ projectHandlers.handleVerifyLock(p, id)
+ );
+ rpcRegister(rpc, "project.pushMigrations", (p, id) =>
+ projectHandlers.handlePushMigrations(p, id)
+ );
+ rpcRegister(rpc, "project.syncMigrations", (p, id) =>
+ projectHandlers.handleSyncMigrations(p, id)
+ );
+ rpcRegister(rpc, "project.generateSQL", (p, id) =>
+ projectHandlers.handleGenerateSQL(p, id)
+ );
+ rpcRegister(rpc, "project.getDrift", (p, id) =>
+ projectHandlers.handleGetDrift(p, id)
+ );
rpcRegister(rpc, "project.getQueries", (p, id) =>
projectHandlers.handleGetQueries(p, id)
);
@@ -274,6 +302,18 @@ export function registerDbHandlers(
rpcRegister(rpc, "project.linkDatabase", (p, id) =>
projectHandlers.handleLinkDatabase(p, id)
);
+ rpcRegister(rpc, "project.unlinkFromConnection", (p, id) =>
+ projectHandlers.handleUnlinkFromConnection(p, id)
+ );
+ rpcRegister(rpc, "project.deleteWithConnection", (p, id) =>
+ projectHandlers.handleDeleteWithConnection(p, id)
+ );
+ rpcRegister(rpc, "project.getGitRemote", (p, id) =>
+ projectHandlers.handleGetGitRemote(p, id)
+ );
+ rpcRegister(rpc, "project.relinkToConnection", (p, id) =>
+ projectHandlers.handleRelinkToConnection(p, id)
+ );
// ==========================================
// GIT HANDLERS
@@ -322,6 +362,34 @@ export function registerDbHandlers(
}
});
+ // ==========================================
+ // AI HANDLERS
+ // ==========================================
+ rpcRegister(rpc, "ai.testConnection", (p, id) =>
+ aiHandlers.handleTestConnection(p, id)
+ );
+ rpcRegister(rpc, "ai.analyzeSchema", (p, id) =>
+ aiHandlers.handleAnalyzeSchema(p, id)
+ );
+ rpcRegister(rpc, "ai.explainQuery", (p, id) =>
+ aiHandlers.handleExplainQuery(p, id)
+ );
+ rpcRegister(rpc, "ai.recommendChart", (p, id) =>
+ aiHandlers.handleRecommendChart(p, id)
+ );
+ rpcRegister(rpc, "ai.getHistory", (p, id) =>
+ aiHandlers.handleGetHistory(p, id)
+ );
+ rpcRegister(rpc, "ai.getHistoryById", (p, id) =>
+ aiHandlers.handleGetHistoryById(p, id)
+ );
+ rpcRegister(rpc, "ai.deleteHistory", (p, id) =>
+ aiHandlers.handleDeleteHistory(p, id)
+ );
+ rpcRegister(rpc, "ai.clearHistory", (p, id) =>
+ aiHandlers.handleClearHistory(p, id)
+ );
+
logger?.info("All RPC handlers registered successfully");
}
diff --git a/bridge/src/queries/README.md b/bridge/src/queries/README.md
new file mode 100644
index 0000000..c8ad53f
--- /dev/null
+++ b/bridge/src/queries/README.md
@@ -0,0 +1,26 @@
+# SQL Queries (Bridge)
+
+This folder contains centralized SQL query strings and SQL helper functions for supported database engines.
+
+Layout
+- `index.ts` - central export for all query modules.
+- `postgres/` - PostgreSQL SQL grouped by schema, tables, constraints, stats, migrations and CRUD.
+- `mysql/` - MySQL SQL grouped by schema, tables, columns, constraints, stats, migrations and CRUD.
+- `sqlite/` - SQLite SQL grouped by schema, tables, constraints, stats, migrations and CRUD.
+
+How it fits
+- Connectors import query constants from this folder and bind parameters through their database driver.
+- Query modules keep SQL text out of service and handler code.
+- Database-specific quoting helpers, such as CRUD identifier quoting, live beside the SQL for that engine.
+
+How to add a query
+1. Add the SQL constant or helper to the matching database folder.
+2. Export it from that database folder's `index.ts` if other modules need central imports.
+3. Use parameter placeholders supported by the target driver.
+4. Keep result column aliases compatible with `src/types/common.ts` where possible.
+5. Add database-specific types under `src/types` only when the common shape is not enough.
+
+Notes
+- Do not concatenate user-controlled identifiers or values directly into SQL. Use driver parameters for values and vetted quote helpers for identifiers.
+- Keep equivalent queries across PostgreSQL, MySQL/MariaDB and SQLite aligned so `QueryExecutor` can return consistent frontend shapes.
+- If a query intentionally excludes system schemas or internal tables, document that behavior near the SQL constant.
diff --git a/bridge/src/queries/mysql/columns.ts b/bridge/src/queries/mysql/columns.ts
index 903d16c..d112632 100644
--- a/bridge/src/queries/mysql/columns.ts
+++ b/bridge/src/queries/mysql/columns.ts
@@ -16,7 +16,11 @@ export const BATCH_GET_ALL_COLUMNS = `
c.ORDINAL_POSITION AS ordinal_position,
c.CHARACTER_MAXIMUM_LENGTH AS max_length,
(c.COLUMN_KEY = 'PRI') AS is_primary_key,
- CASE WHEN fk.COLUMN_NAME IS NOT NULL THEN TRUE ELSE FALSE END AS is_foreign_key
+ CASE WHEN fk.COLUMN_NAME IS NOT NULL THEN TRUE ELSE FALSE END AS is_foreign_key,
+ (c.COLUMN_KEY = 'UNI' OR c.COLUMN_KEY = 'PRI') AS is_unique,
+ (c.EXTRA LIKE '%auto_increment%') AS is_serial,
+ c.COLUMN_COMMENT AS comment,
+ NULL AS check_constraint
FROM information_schema.columns c
LEFT JOIN (
SELECT DISTINCT kcu.TABLE_SCHEMA, kcu.TABLE_NAME, kcu.COLUMN_NAME
diff --git a/bridge/src/queries/postgres/tables.ts b/bridge/src/queries/postgres/tables.ts
index a3fa7fa..423019a 100644
--- a/bridge/src/queries/postgres/tables.ts
+++ b/bridge/src/queries/postgres/tables.ts
@@ -28,8 +28,7 @@ export const PG_GET_TABLE_DETAILS = `
`;
/**
- * Get all columns in a schema (batch query)
- * @param schemaName - Use $1 placeholder
+ * Batch get all columns for all tables in a schema
*/
export const PG_BATCH_GET_ALL_COLUMNS = `
SELECT
@@ -56,7 +55,27 @@ export const PG_BATCH_GET_ALL_COLUMNS = `
AND tc.table_name = c.table_name
AND kcu.column_name = c.column_name),
FALSE
- ) AS is_foreign_key
+ ) AS is_foreign_key,
+ COALESCE(
+ (SELECT TRUE FROM information_schema.table_constraints tc
+ JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
+ WHERE tc.constraint_type = 'UNIQUE'
+ AND tc.table_schema = c.table_schema
+ AND tc.table_name = c.table_name
+ AND kcu.column_name = c.column_name),
+ FALSE
+ ) AS is_unique,
+ (c.column_default ILIKE '%nextval%' OR c.is_identity = 'YES') AS is_serial,
+ (SELECT pg_catalog.col_description(format('%I.%I', c.table_schema, c.table_name)::regclass::oid, c.ordinal_position)) AS comment,
+ (SELECT cc.check_clause
+ FROM information_schema.table_constraints tc
+ JOIN information_schema.check_constraints cc ON tc.constraint_name = cc.constraint_name
+ JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
+ WHERE tc.constraint_type = 'CHECK'
+ AND tc.table_schema = c.table_schema
+ AND tc.table_name = c.table_name
+ AND ccu.column_name = c.column_name
+ LIMIT 1) AS check_constraint
FROM information_schema.columns c
WHERE c.table_schema = $1
ORDER BY c.table_name, c.ordinal_position;
diff --git a/bridge/src/services/README.md b/bridge/src/services/README.md
new file mode 100644
index 0000000..b248036
--- /dev/null
+++ b/bridge/src/services/README.md
@@ -0,0 +1,37 @@
+# Services (Bridge)
+
+This folder contains bridge business logic and persistence services. Services sit between RPC handlers and low-level connectors, stores, keyrings, Git, filesystem and monitoring utilities.
+
+Layout
+- `databaseService.ts` - database metadata lifecycle, credential lookup and connection config retrieval.
+- `queryExecutor.ts` - database-agnostic dispatcher for schema, table, query, CRUD and migration operations.
+- `connectionBuilder.ts` - builds driver config objects and optional SSH tunnel wiring.
+- `connectionPool.ts` - caches resolved connection configs and tunnel handles by database id.
+- `connectorRegistry.ts` - maps database types to connector implementations.
+- `dbStore.ts` - persisted database connection metadata and credential references.
+- `projectStore.ts` - project files, schema snapshots, ER diagrams, annotations, saved queries and local project config.
+- `gitService.ts` - local Git operations used by Git handlers.
+- `keyringService.ts` - encrypted credential storage through `@napi-rs/keyring`.
+- `sshTunnelService.ts` - SSH tunnel creation and cleanup.
+- `discoveryService.ts` - local database discovery.
+- `monitoringService.ts` and `monitoringWebSocketServer.ts` - database monitoring snapshots and websocket broadcast support.
+- `ai.impl.ts`, `aiService.ts`, `aiCacheService.ts`, `aiHistoryStore.ts` - AI service factory, compatibility shim, cache and history persistence.
+- `logger.ts` - shared pino logger.
+
+How it fits
+- Handlers call services to do real work; services should not know about UI components.
+- Services call connectors, stores and utilities, then return plain data to handlers.
+- Persistent paths are defined in `src/utils/config.ts`.
+- Shared types come from `src/types`.
+
+How to add service behavior
+1. Put durable business logic here when it is shared by multiple handlers or has state/persistence concerns.
+2. Keep public methods small and typed enough for handler use.
+3. Let handlers translate thrown errors into JSON-RPC errors.
+4. Invalidate connection or metadata caches when mutating database/project state.
+5. Add focused tests under `bridge/__tests__` for services with persistence, Git, credential or connector logic.
+
+Notes
+- Keep secrets out of logs and return values.
+- Avoid long-lived open database sockets unless the connection pool explicitly owns them.
+- When adding filesystem writes, use paths derived from `src/utils/config.ts` so RelWave respects `RELWAVE_HOME`.
diff --git a/bridge/src/services/ai.impl.ts b/bridge/src/services/ai.impl.ts
new file mode 100644
index 0000000..3554216
--- /dev/null
+++ b/bridge/src/services/ai.impl.ts
@@ -0,0 +1,60 @@
+import { AISettings, AIProviderName } from "../types/ai";
+import { AIProvider, AIError } from "../ai/providers/types";
+import { AnthropicProvider } from "../ai/providers/anthropic.provider";
+import { OpenAIProvider } from "../ai/providers/openai.provider";
+import { GeminiProvider } from "../ai/providers/gemini.provider";
+import { GroqProvider } from "../ai/providers/groq.provider";
+import { MistralProvider } from "../ai/providers/mistral.provider";
+import { OllamaProvider } from "../ai/providers/ollama.provider";
+
+/**
+ * Factory — creates the correct provider from user settings.
+ * Throws AIError("MISSING_API_KEY") if the required credential is absent.
+ */
+export class AIServiceImpl {
+ /**
+ * Resolve the active provider from the supplied settings.
+ */
+ resolveProvider(settings: AISettings): AIProvider {
+ const provider = settings.defaultProvider;
+ return this.createProvider(provider, settings);
+ }
+
+ private createProvider(name: AIProviderName, settings: AISettings): AIProvider {
+ switch (name) {
+ case "anthropic": {
+ const key = settings.anthropicApiKey?.trim();
+ if (!key) throw new AIError("MISSING_API_KEY", "anthropic", "Anthropic API key is not configured.");
+ return new AnthropicProvider(key);
+ }
+ case "openai": {
+ const key = settings.openaiApiKey?.trim();
+ if (!key) throw new AIError("MISSING_API_KEY", "openai", "OpenAI API key is not configured.");
+ return new OpenAIProvider(key);
+ }
+ case "gemini": {
+ const key = settings.geminiApiKey?.trim();
+ if (!key) throw new AIError("MISSING_API_KEY", "gemini", "Gemini API key is not configured.");
+ return new GeminiProvider(key);
+ }
+ case "groq": {
+ const key = settings.groqApiKey?.trim();
+ if (!key) throw new AIError("MISSING_API_KEY", "groq", "Groq API key is not configured.");
+ return new GroqProvider(key);
+ }
+ case "mistral": {
+ const key = settings.mistralApiKey?.trim();
+ if (!key) throw new AIError("MISSING_API_KEY", "mistral", "Mistral API key is not configured.");
+ return new MistralProvider(key);
+ }
+ case "ollama": {
+ return new OllamaProvider(settings.ollamaBaseUrl, settings.ollamaModel);
+ }
+ default: {
+ throw new AIError("PROVIDER_UNAVAILABLE", name as string, `Unknown provider: ${name}`);
+ }
+ }
+ }
+}
+
+export const aiImpl = new AIServiceImpl();
diff --git a/bridge/src/services/aiCacheService.ts b/bridge/src/services/aiCacheService.ts
new file mode 100644
index 0000000..6294668
--- /dev/null
+++ b/bridge/src/services/aiCacheService.ts
@@ -0,0 +1,172 @@
+/**
+ * AI Cache Service — orchestrates cache-first AI calls.
+ *
+ * Before calling an LLM provider, generates a deterministic SHA-256 hash
+ * of the input and checks the local SQLite history for a cached response.
+ * If found, returns immediately without an API call. Otherwise, calls the
+ * provider, stores the result, and returns it.
+ */
+
+import crypto from "crypto";
+import { aiHistoryStore, type AIHistoryInsert } from "./aiHistoryStore";
+import { AIProvider } from "../ai/providers/types";
+import {
+ AISettings,
+ SchemaAnalysisInput,
+ QueryExplanationInput,
+ ChartRecommendationInput,
+} from "../types/ai";
+
+// ── Types ─────────────────────────────────────────────────────────────────
+
+export type AIFeature = "schema-analysis" | "query-explanation" | "chart-recommendation";
+
+export interface CachedResult {
+ response: string;
+ cached: boolean;
+ createdAt?: string;
+}
+
+// ── Hashing ───────────────────────────────────────────────────────────────
+
+/**
+ * Generate a deterministic SHA-256 hash for the given feature and input.
+ * The same input always produces the same hash.
+ */
+export function generateContentHash(feature: AIFeature, data: unknown): string {
+ const payload = JSON.stringify({ feature, data }, Object.keys({ feature, data }).sort());
+ return crypto.createHash("sha256").update(payload, "utf-8").digest("hex");
+}
+
+/** Hash for schema analysis: uses datasource + tables, columns, indexes, relationships, constraints. */
+export function hashSchemaAnalysis(input: SchemaAnalysisInput, datasourceName?: string): string {
+ const normalized = {
+ datasource: datasourceName ?? null,
+ databaseType: input.databaseType ?? null,
+ tables: (input.tables ?? []).map((t) => ({
+ name: t.name,
+ schema: t.schema ?? null,
+ columns: (t.columns ?? []).map((c) => ({
+ name: c.name,
+ type: c.type,
+ nullable: c.nullable ?? null,
+ isPrimaryKey: c.isPrimaryKey ?? null,
+ isForeignKey: c.isForeignKey ?? null,
+ references: c.references ?? null,
+ })),
+ indexes: t.indexes ?? [],
+ foreignKeys: t.foreignKeys ?? [],
+ constraints: t.constraints ?? [],
+ })),
+ };
+ return generateContentHash("schema-analysis", normalized);
+}
+
+/** Hash for query explanation: uses datasource + sql + schema. */
+export function hashQueryExplanation(input: QueryExplanationInput, datasourceName?: string): string {
+ const normalized = {
+ datasource: datasourceName ?? null,
+ sql: (input.sql ?? "").trim(),
+ databaseType: input.databaseType ?? null,
+ schema: (input.schema ?? []).map((t) => ({
+ name: t.name,
+ schema: t.schema ?? null,
+ columns: (t.columns ?? []).map((c) => ({
+ name: c.name,
+ type: c.type,
+ })),
+ })),
+ };
+ return generateContentHash("query-explanation", normalized);
+}
+
+/** Hash for chart recommendation: uses datasource + tableName + columns. */
+export function hashChartRecommendation(input: ChartRecommendationInput, datasourceName?: string): string {
+ const normalized = {
+ datasource: datasourceName ?? null,
+ tableName: input.tableName,
+ columns: (input.columns ?? []).map((c) => ({
+ name: c.name,
+ type: c.type,
+ isPrimaryKey: c.isPrimaryKey ?? null,
+ })),
+ };
+ return generateContentHash("chart-recommendation", normalized);
+}
+
+// ── Cache-first orchestration ─────────────────────────────────────────────
+
+/**
+ * Resolve the model name from the settings based on provider.
+ * This is best-effort — some providers don't expose the model in settings.
+ */
+function resolveModelName(settings: AISettings): string {
+ const provider = settings.defaultProvider;
+ switch (provider) {
+ case "ollama":
+ return settings.ollamaModel ?? "ollama-default";
+ default:
+ return provider; // For API-key providers, the model is selected by the SDK
+ }
+}
+
+/**
+ * Check cache, and if missed, call the provider and store the result.
+ *
+ * @param feature The AI feature name (schema-analysis, query-explanation, chart-recommendation)
+ * @param hash Content hash for cache lookup
+ * @param settings User's AI settings (provider, keys)
+ * @param prompt The prompt text (for logging / history display)
+ * @param callFn Async function that calls the actual AI provider
+ * @param meta Optional metadata (datasource_id, table_name)
+ * @param skipCache If true, bypass cache and force a fresh call
+ */
+export async function getOrCall(opts: {
+ feature: AIFeature;
+ hash: string;
+ settings: AISettings;
+ prompt: string;
+ callFn: () => Promise;
+ meta?: { datasource_id?: string; table_name?: string };
+ skipCache?: boolean;
+}): Promise {
+ const { feature, hash, settings, prompt, callFn, meta, skipCache } = opts;
+
+ // Check cache (unless skipCache is true)
+ if (!skipCache) {
+ const cached = aiHistoryStore.findByHash(hash, meta?.datasource_id);
+ if (cached) {
+ return {
+ response: cached.response,
+ cached: true,
+ createdAt: cached.created_at,
+ };
+ }
+ }
+
+ // Cache miss — call the provider
+ const response = await callFn();
+
+ // Estimate tokens: ~4 characters per token is a reasonable approximation
+ // for English text across most LLM tokenizers.
+ const estimatedTokens = Math.ceil((prompt.length + response.length) / 4);
+
+ // Store in history
+ const record: AIHistoryInsert = {
+ feature,
+ datasource_id: meta?.datasource_id ?? null,
+ table_name: meta?.table_name ?? null,
+ content_hash: hash,
+ provider: settings.defaultProvider,
+ model: resolveModelName(settings),
+ prompt,
+ response,
+ tokens_used: estimatedTokens,
+ };
+ aiHistoryStore.insert(record);
+
+ return {
+ response,
+ cached: false,
+ };
+}
diff --git a/bridge/src/services/aiHistoryStore.ts b/bridge/src/services/aiHistoryStore.ts
new file mode 100644
index 0000000..726f8c1
--- /dev/null
+++ b/bridge/src/services/aiHistoryStore.ts
@@ -0,0 +1,258 @@
+/**
+ * AI History Store — SQLite-backed repository for AI analysis history.
+ *
+ * Stores AI responses locally so the user can browse previous analyses,
+ * and provides the persistence layer for the cache system.
+ */
+
+import Database from "better-sqlite3";
+import path from "path";
+import { resolvePkgNativeBindingPath } from "../connectors/sqlite";
+import fs from "fs";
+import { CONFIG_FOLDER } from "../utils/config";
+
+// ── Types ─────────────────────────────────────────────────────────────────
+
+export interface AIHistoryRow {
+ id: number;
+ feature: string;
+ datasource_id: string | null;
+ table_name: string | null;
+ content_hash: string | null;
+ provider: string;
+ model: string;
+ prompt: string;
+ response: string;
+ tokens_used: number | null;
+ created_at: string;
+}
+
+/** Subset returned when listing (no prompt/response to keep payloads small). */
+export interface AIHistoryListItem {
+ id: number;
+ feature: string;
+ datasource_id: string | null;
+ table_name: string | null;
+ provider: string;
+ model: string;
+ tokens_used: number | null;
+ created_at: string;
+}
+
+export interface AIHistoryInsert {
+ feature: string;
+ datasource_id?: string | null;
+ table_name?: string | null;
+ content_hash?: string | null;
+ provider: string;
+ model: string;
+ prompt: string;
+ response: string;
+ tokens_used?: number | null;
+}
+
+export interface AIHistoryListParams {
+ feature?: string;
+ provider?: string;
+ limit?: number;
+ offset?: number;
+}
+
+export interface AIHistoryListResult {
+ items: AIHistoryListItem[];
+ total: number;
+}
+
+// ── DDL ───────────────────────────────────────────────────────────────────
+
+const CREATE_TABLE_SQL = `
+CREATE TABLE IF NOT EXISTS ai_history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ feature TEXT NOT NULL,
+ datasource_id TEXT,
+ table_name TEXT,
+ content_hash TEXT,
+ provider TEXT NOT NULL,
+ model TEXT NOT NULL,
+ prompt TEXT NOT NULL,
+ response TEXT NOT NULL,
+ tokens_used INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+`;
+
+const CREATE_INDEX_HASH = `CREATE INDEX IF NOT EXISTS idx_ai_history_hash ON ai_history(content_hash);`;
+const CREATE_INDEX_HASH_DS = `CREATE INDEX IF NOT EXISTS idx_ai_history_hash_ds ON ai_history(content_hash, datasource_id);`;
+const CREATE_INDEX_FEATURE = `CREATE INDEX IF NOT EXISTS idx_ai_history_feature ON ai_history(feature);`;
+const CREATE_INDEX_CREATED = `CREATE INDEX IF NOT EXISTS idx_ai_history_created_at ON ai_history(created_at);`;
+
+// ── Store ─────────────────────────────────────────────────────────────────
+
+export class AIHistoryStore {
+ private db: Database.Database | null = null;
+ private dbPath: string;
+
+ constructor(dbPath?: string) {
+ this.dbPath = dbPath ?? path.join(CONFIG_FOLDER, "ai_history.db");
+ }
+
+ /** Lazily open the database and run migrations. */
+ private getDb(): Database.Database {
+ if (this.db) return this.db;
+
+ // Ensure the config directory exists
+ const dir = path.dirname(this.dbPath);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+
+ // Resolve native binding for pkg builds (reuse existing pattern)
+ let nativeBinding: string | undefined;
+ try {
+ nativeBinding = resolvePkgNativeBindingPath() ?? undefined;
+ } catch {
+ // Ignore if it fails for any reason
+ }
+
+ const opts: Database.Options = {};
+ if (nativeBinding) opts.nativeBinding = nativeBinding;
+
+ this.db = new Database(this.dbPath, opts);
+
+ // Enable WAL mode for better concurrent read performance
+ this.db.pragma("journal_mode = WAL");
+
+ // Run migrations
+ this.db.exec(CREATE_TABLE_SQL);
+ this.db.exec(CREATE_INDEX_HASH);
+ this.db.exec(CREATE_INDEX_HASH_DS);
+ this.db.exec(CREATE_INDEX_FEATURE);
+ this.db.exec(CREATE_INDEX_CREATED);
+
+ return this.db;
+ }
+
+ /** Insert a new history record. Returns the new row ID. */
+ insert(record: AIHistoryInsert): number {
+ const db = this.getDb();
+ const stmt = db.prepare(`
+ INSERT INTO ai_history (feature, datasource_id, table_name, content_hash, provider, model, prompt, response, tokens_used, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `);
+ const info = stmt.run(
+ record.feature,
+ record.datasource_id ?? null,
+ record.table_name ?? null,
+ record.content_hash ?? null,
+ record.provider,
+ record.model,
+ record.prompt,
+ record.response,
+ record.tokens_used ?? null,
+ new Date().toISOString()
+ );
+ return info.lastInsertRowid as number;
+ }
+
+ /**
+ * Look up a cached result by content_hash AND datasource_id.
+ * Both must match — a cached response for Database A won't be
+ * returned when querying Database B, even with the same schema.
+ */
+ findByHash(contentHash: string, datasourceId?: string | null): AIHistoryRow | null {
+ const db = this.getDb();
+ let row: AIHistoryRow | undefined;
+
+ if (datasourceId) {
+ // Match both hash and the specific datasource
+ row = db
+ .prepare(
+ `SELECT * FROM ai_history WHERE content_hash = ? AND datasource_id = ? ORDER BY created_at DESC LIMIT 1`
+ )
+ .get(contentHash, datasourceId) as AIHistoryRow | undefined;
+ } else {
+ // No datasource specified — only match rows that also have no datasource
+ row = db
+ .prepare(
+ `SELECT * FROM ai_history WHERE content_hash = ? AND datasource_id IS NULL ORDER BY created_at DESC LIMIT 1`
+ )
+ .get(contentHash) as AIHistoryRow | undefined;
+ }
+
+ return row ?? null;
+ }
+
+ /** List history entries with optional filters and pagination. */
+ list(params: AIHistoryListParams = {}): AIHistoryListResult {
+ const db = this.getDb();
+ const conditions: string[] = [];
+ const values: unknown[] = [];
+
+ if (params.feature) {
+ conditions.push("feature = ?");
+ values.push(params.feature);
+ }
+ if (params.provider) {
+ conditions.push("provider = ?");
+ values.push(params.provider);
+ }
+
+ const where = conditions.length
+ ? `WHERE ${conditions.join(" AND ")}`
+ : "";
+
+ const limit = params.limit ?? 20;
+ const offset = params.offset ?? 0;
+
+ // Count
+ const countRow = db
+ .prepare(`SELECT COUNT(*) AS total FROM ai_history ${where}`)
+ .get(...values) as { total: number };
+
+ // Fetch items (no prompt/response for list view)
+ const items = db
+ .prepare(
+ `SELECT id, feature, datasource_id, table_name, provider, model, tokens_used, created_at
+ FROM ai_history ${where}
+ ORDER BY created_at DESC
+ LIMIT ? OFFSET ?`
+ )
+ .all(...values, limit, offset) as AIHistoryListItem[];
+
+ return { items, total: countRow.total };
+ }
+
+ /** Get a single history entry by ID (full record). */
+ getById(id: number): AIHistoryRow | null {
+ const db = this.getDb();
+ const row = db
+ .prepare(`SELECT * FROM ai_history WHERE id = ?`)
+ .get(id) as AIHistoryRow | undefined;
+ return row ?? null;
+ }
+
+ /** Delete a single history entry. Returns true if deleted. */
+ deleteById(id: number): boolean {
+ const db = this.getDb();
+ const info = db.prepare(`DELETE FROM ai_history WHERE id = ?`).run(id);
+ return info.changes > 0;
+ }
+
+ /** Clear all history entries. Returns number of rows deleted. */
+ clearAll(): number {
+ const db = this.getDb();
+ const info = db.prepare(`DELETE FROM ai_history`).run();
+ return info.changes;
+ }
+
+ /** Close the database connection (for clean shutdown). */
+ close(): void {
+ if (this.db) {
+ this.db.close();
+ this.db = null;
+ }
+ }
+}
+
+// Singleton instance
+export const aiHistoryStore = new AIHistoryStore();
diff --git a/bridge/src/services/aiService.ts b/bridge/src/services/aiService.ts
new file mode 100644
index 0000000..09036ac
--- /dev/null
+++ b/bridge/src/services/aiService.ts
@@ -0,0 +1,7 @@
+import { AIServiceImpl } from "./ai.impl";
+
+// Re-export the AIService class for discoverability under services/
+export class AIService extends AIServiceImpl {}
+
+// Also provide a singleton instance for callers that prefer a shared object
+export const aiService = new AIService();
diff --git a/bridge/src/services/gitService.ts b/bridge/src/services/gitService.ts
index f96c04e..fbf5b5f 100644
--- a/bridge/src/services/gitService.ts
+++ b/bridge/src/services/gitService.ts
@@ -87,6 +87,23 @@ export interface GitBranchInfo {
upstream: string | null;
}
+export type GitSyncResult = {
+ staged: string[];
+ commitHash: string;
+ commitMessage: string;
+ pushed: boolean;
+ error?: { code: string; message: string };
+};
+
+export class GitError extends Error {
+ code: string;
+ constructor(code: string, message: string) {
+ super(message);
+ this.code = code;
+ this.name = "GitError";
+ }
+}
+
export class GitService {
/**
* Run a git command in a specific directory.
@@ -1088,6 +1105,205 @@ export class GitService {
async renameBranch(dir: string, newName: string): Promise {
await this.git(dir, "branch", "-m", newName);
}
+
+ // ==========================================
+ // Migration Sync (Task 1)
+ // ==========================================
+
+ /**
+ * Stage migration files and lock file
+ */
+ async stageMigrationFiles(projectPath: string): Promise {
+ // Run git add on migrations/ and migration-lock.json
+ // But first check if they exist or have changes
+ const filesToStage = ["migrations"];
+ const stagedPaths: string[] = [];
+
+ for (const file of filesToStage) {
+ try {
+ await this.git(projectPath, "add", file);
+ stagedPaths.push(file);
+ } catch (err) {
+ // Ignore if path doesn't exist
+ }
+ }
+
+ // Return actually staged files from status
+ const changes = await this.getChangedFiles(projectPath);
+ return changes.filter(c => c.staged).map(c => c.path);
+ }
+
+ /**
+ * Commit migration files with an auto-generated or provided message
+ */
+ async commitMigrationFiles(projectPath: string, message?: string): Promise {
+ let commitMsg = message;
+
+ if (!commitMsg) {
+ // Auto-generate message based on staged files
+ const changes = await this.getChangedFiles(projectPath);
+ const stagedMigrations = changes.filter(c => c.staged && c.path.startsWith("migrations/"));
+
+ if (stagedMigrations.length === 1) {
+ const filename = path.basename(stagedMigrations[0].path);
+ if (filename.includes("baseline")) {
+ commitMsg = "migrate: add baseline migration from schema snapshot";
+ } else {
+ commitMsg = `migrate: apply ${filename}`;
+ }
+ } else if (stagedMigrations.length > 1) {
+ // Find highest migration
+ const sorted = stagedMigrations.map(m => path.basename(m.path)).sort();
+ const latest = sorted[sorted.length - 1];
+ commitMsg = `migrate: apply ${stagedMigrations.length} migrations up to ${latest}`;
+ } else {
+ commitMsg = "migrate: update schema lock";
+ }
+ }
+
+ return this.commit(projectPath, commitMsg);
+ }
+
+ /**
+ * Push current branch to remote
+ */
+ async pushMigrations(projectPath: string): Promise {
+ const remotes = await this.remoteList(projectPath);
+ if (remotes.length === 0) {
+ throw new GitError("NO_REMOTE", "No remote configured for this repository");
+ }
+
+ try {
+ await this.git(projectPath, "push");
+ } catch (err: any) {
+ const errorMsg = err.stderr || err.message || "";
+ if (errorMsg.includes("non-fast-forward") || errorMsg.includes("fetch first")) {
+ throw new GitError("PUSH_REJECTED", "Push rejected due to diverged history");
+ }
+ throw new GitError("PUSH_FAILED", `Push failed: ${errorMsg}`);
+ }
+ }
+
+ /**
+ * Orchestrate staging, committing, and pushing
+ */
+ async syncMigrationFiles(projectPath: string, message?: string): Promise {
+ const result: GitSyncResult = {
+ staged: [],
+ commitHash: "",
+ commitMessage: "",
+ pushed: false,
+ };
+
+ try {
+ result.staged = await this.stageMigrationFiles(projectPath);
+ if (result.staged.length === 0) {
+ return result; // Nothing to sync
+ }
+
+ result.commitHash = await this.commitMigrationFiles(projectPath, message);
+
+ // Get actual message used if auto-generated
+ if (!message && result.commitHash) {
+ const log = await this.log(projectPath, 1);
+ if (log.length > 0) result.commitMessage = log[0].subject;
+ } else {
+ result.commitMessage = message || "";
+ }
+
+ // Read config to see if autoPushMigrations is true
+ let autoPush = false;
+ try {
+ const configPath = path.join(projectPath, "relwave.json");
+ if (fsSync.existsSync(configPath)) {
+ const config = JSON.parse(fsSync.readFileSync(configPath, "utf-8"));
+ if (config.autoPushMigrations === true) {
+ autoPush = true;
+ }
+ }
+ } catch (err) {
+ // Ignore config read errors
+ }
+
+ if (autoPush) {
+ try {
+ await this.pushMigrations(projectPath);
+ result.pushed = true;
+ } catch (pushErr: any) {
+ result.error = {
+ code: pushErr.code || "PUSH_FAILED",
+ message: pushErr.message
+ };
+ }
+ }
+
+ } catch (err: any) {
+ result.error = {
+ code: err.code || "SYNC_FAILED",
+ message: err.message
+ };
+ }
+
+ return result;
+ }
+
+ /**
+ * Stash, sync schema.json
+ */
+ async syncSchemaFile(projectPath: string, message?: string): Promise {
+ try {
+ await this.ensureGitignore(projectPath);
+ const changes = await this.getChangedFiles(projectPath);
+ const schemaFile = "schema/schema.json";
+
+ const schemaModified = changes.some(c => c.path === schemaFile);
+
+ if (!schemaModified) {
+ return { staged: [], commitHash: "", commitMessage: "", pushed: false };
+ }
+
+ await this.stageFiles(projectPath, [schemaFile]);
+
+ const commitMessage = message || `chore: update schema.json cache`;
+ const commitHash = await this.commit(projectPath, commitMessage);
+
+ let pushed = false;
+ try {
+ await this.pushMigrations(projectPath);
+ pushed = true;
+ } catch (pushErr: any) {
+ // Return result with push error but still considered successful commit
+ return {
+ staged: [schemaFile],
+ commitHash,
+ commitMessage,
+ pushed: false,
+ error: {
+ code: "GIT_PUSH_FAILED",
+ message: `Committed successfully but failed to push: ${pushErr.message || String(pushErr)}`
+ }
+ };
+ }
+
+ return {
+ staged: [schemaFile],
+ commitHash,
+ commitMessage,
+ pushed
+ };
+ } catch (error: any) {
+ return {
+ staged: [],
+ commitHash: "",
+ commitMessage: "",
+ pushed: false,
+ error: {
+ code: "GIT_SYNC_FAILED",
+ message: `Failed to sync schema file: ${error.message || String(error)}`
+ }
+ };
+ }
+ }
}
export const gitServiceInstance = new GitService();
diff --git a/bridge/src/services/migrationLock.ts b/bridge/src/services/migrationLock.ts
new file mode 100644
index 0000000..efa358b
--- /dev/null
+++ b/bridge/src/services/migrationLock.ts
@@ -0,0 +1,51 @@
+import fs from "fs/promises";
+import path from "path";
+import fsSync from "fs";
+import { projectStoreInstance } from "./projectStore";
+
+export interface MigrationLock {
+ version: string;
+ schemaHash: string;
+ appliedMigrations: string[]; // List of migration filenames
+ updatedAt: string;
+}
+
+export async function getLockFilePath(dbId: string): Promise {
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
+ return path.join(migrationsDir, "migration.lock.json");
+}
+
+export async function readMigrationLock(dbId: string): Promise {
+ const lockPath = await getLockFilePath(dbId);
+ if (!fsSync.existsSync(lockPath)) return null;
+
+ try {
+ const raw = await fs.readFile(lockPath, "utf8");
+ return JSON.parse(raw) as MigrationLock;
+ } catch (err) {
+ return null;
+ }
+}
+
+export async function writeMigrationLock(
+ dbId: string,
+ schemaHash: string,
+ appliedMigrations: string[]
+): Promise {
+ const lockPath = await getLockFilePath(dbId);
+ const lockData: MigrationLock = {
+ version: "1",
+ schemaHash,
+ appliedMigrations,
+ updatedAt: new Date().toISOString()
+ };
+
+ await fs.mkdir(path.dirname(lockPath), { recursive: true }).catch(() => {});
+ await fs.writeFile(lockPath, JSON.stringify(lockData, null, 2), "utf8");
+}
+
+export async function verifyMigrationLock(dbId: string, targetHash: string): Promise {
+ const lock = await readMigrationLock(dbId);
+ if (!lock) return false;
+ return lock.schemaHash === targetHash;
+}
diff --git a/bridge/src/services/projectStore.ts b/bridge/src/services/projectStore.ts
index 79c93c2..01af84a 100644
--- a/bridge/src/services/projectStore.ts
+++ b/bridge/src/services/projectStore.ts
@@ -21,7 +21,8 @@ import { dbStoreInstance, DBMeta } from "./dbStore";
export type ProjectMetadata = {
version: number;
id: string;
- databaseId: string;
+ databaseId: string | null;
+ status?: "active" | "unlinked";
name: string;
description?: string;
engine?: string;
@@ -74,22 +75,31 @@ export type AnnotationsFile = {
};
export type SchemaFile = {
- version: number;
+ version: number; // bumped to 2
projectId: string;
databaseId: string;
+ dialect: "postgresql" | "mysql" | "sqlite" | "unknown";
schemas: SchemaSnapshot[];
cachedAt: string;
+ relwaveVersion: string;
+ schemaHash: string; // SHA-256 of entire schema content
};
export type SchemaSnapshot = {
name: string;
tables: TableSnapshot[];
+ enums?: EnumSnapshot[];
+ views?: ViewSnapshot[];
};
export type TableSnapshot = {
name: string;
type: string;
columns: ColumnSnapshot[];
+ indexes: IndexSnapshot[];
+ foreignKeys: ForeignKeySnapshot[];
+ checks: CheckConstraint[];
+ comment?: string | null;
};
export type ColumnSnapshot = {
@@ -100,11 +110,54 @@ export type ColumnSnapshot = {
isForeignKey: boolean;
defaultValue: string | null;
isUnique: boolean;
+ isSerial: boolean;
+ ordinalPosition: number;
+ foreignKey?: {
+ schema: string;
+ table: string;
+ column: string;
+ onDelete?: "CASCADE" | "SET NULL" | "RESTRICT" | "NO ACTION";
+ onUpdate?: "CASCADE" | "SET NULL" | "RESTRICT" | "NO ACTION";
+ };
+ checkConstraint?: string;
+ comment?: string | null;
+};
+
+export type IndexSnapshot = {
+ name: string;
+ columns: string[];
+ unique: boolean;
+ partial?: string | null;
+};
+
+export type ForeignKeySnapshot = {
+ name: string;
+ columns: string[];
+ referencedSchema: string;
+ referencedTable: string;
+ referencedColumns: string[];
+ onDelete: "CASCADE" | "SET NULL" | "RESTRICT" | "NO ACTION" | "SET DEFAULT";
+ onUpdate: "CASCADE" | "SET NULL" | "RESTRICT" | "NO ACTION" | "SET DEFAULT";
+};
+
+export type CheckConstraint = {
+ name: string;
+ expression: string;
+};
+
+export type EnumSnapshot = {
+ name: string;
+ values: string[];
+};
+
+export type ViewSnapshot = {
+ name: string;
+ definition: string;
};
export type ProjectSummary = Pick<
ProjectMetadata,
- "id" | "name" | "description" | "engine" | "databaseId" | "sourcePath" | "createdAt" | "updatedAt"
+ "id" | "name" | "description" | "engine" | "databaseId" | "sourcePath" | "createdAt" | "updatedAt" | "status"
>;
/**
@@ -339,7 +392,7 @@ export class ProjectStore {
// Resolve engine from the linked database
let engine: string | undefined;
try {
- const db: DBMeta | null = await dbStoreInstance.getDB(params.databaseId);
+ const db: DBMeta | undefined = await dbStoreInstance.getDB(params.databaseId);
engine = db?.type;
} catch {
// db may not exist yet — that's OK
@@ -356,6 +409,7 @@ export class ProjectStore {
description: params.description,
engine,
defaultSchema: params.defaultSchema,
+ status: "active",
createdAt: now,
updatedAt: now,
};
@@ -371,11 +425,14 @@ export class ProjectStore {
// Initialise empty sub-files
const emptySchema: SchemaFile = {
- version: 1,
+ version: 2,
projectId: id,
databaseId: params.databaseId,
+ dialect: engine === "postgresql" ? "postgresql" : engine === "mysql" ? "mysql" : "sqlite", // Provide fallback or infer
schemas: [],
cachedAt: now,
+ relwaveVersion: "1.0.0", // Hardcoded for now
+ schemaHash: "",
};
const emptyER: ERDiagramFile = {
version: 1,
@@ -416,6 +473,7 @@ export class ProjectStore {
description: meta.description,
engine,
databaseId: meta.databaseId,
+ status: "active",
createdAt: now,
updatedAt: now,
});
@@ -509,7 +567,7 @@ export class ProjectStore {
// Resolve engine from the linked database
let engine: string | undefined;
try {
- const db: DBMeta | null = await dbStoreInstance.getDB(databaseId);
+ const db: DBMeta | undefined = await dbStoreInstance.getDB(databaseId);
engine = db?.type;
} catch {
// db may not exist yet
@@ -566,12 +624,161 @@ export class ProjectStore {
};
}
+ /**
+ * Unlink a database connection from a project (leaving the project orphaned but intact)
+ */
+ async unlinkDatabase(databaseId: string): Promise {
+ const meta = await this.getProjectByDatabaseId(databaseId);
+ if (!meta) return;
+
+ const now = new Date().toISOString();
+
+ await this.ensureSourcePathCache();
+ const isImported = this.sourcePathCache.has(meta.id);
+
+ if (isImported) {
+ // ---- Imported project: write to local config only ----
+ const local = (await this.getLocalConfig(meta.id)) ?? {};
+ local.databaseId = undefined;
+ await this.saveLocalConfig(meta.id, local);
+ } else {
+ // ---- Regular project: update relwave.json ----
+ const updated: ProjectMetadata = {
+ ...meta,
+ databaseId: null,
+ status: "unlinked",
+ updatedAt: now,
+ };
+ await this.writeJSON(
+ this.projectFile(meta.id, PROJECT_FILES.metadata),
+ updated
+ );
+ }
+
+ // Sync the index entry
+ const index = await this.loadIndex();
+ const entry = index.projects.find((p) => p.id === meta.id);
+ if (entry) {
+ entry.databaseId = null;
+ entry.status = "unlinked";
+ entry.updatedAt = now;
+ await this.saveIndex(index);
+ }
+ }
+
+ /**
+ * Relink an unlinked project to a new connection.
+ * Throws an error if the new connection is already linked to another project.
+ */
+ async relinkDatabase(projectId: string, databaseId: string): Promise {
+ // 1. Verify connection is not already linked to another project
+ const existingLinkedProject = await this.getProjectByDatabaseId(databaseId);
+ if (existingLinkedProject && existingLinkedProject.id !== projectId) {
+ throw new Error(`DATABASE_ALREADY_HAS_PROJECT: This connection is already linked to project "${existingLinkedProject.name}"`);
+ }
+
+ // 2. Link database
+ const updated = await this.linkDatabase(projectId, databaseId);
+ if (!updated) {
+ throw new Error(`Project ${projectId} not found`);
+ }
+
+ const now = new Date().toISOString();
+
+ // 3. Mark project as active again
+ await this.ensureSourcePathCache();
+ const isImported = this.sourcePathCache.has(projectId);
+
+ if (!isImported) {
+ const finalUpdated: ProjectMetadata = {
+ ...updated,
+ status: "active",
+ updatedAt: now,
+ };
+ await this.writeJSON(
+ this.projectFile(projectId, PROJECT_FILES.metadata),
+ finalUpdated
+ );
+ }
+
+ // Sync the index entry
+ const index = await this.loadIndex();
+ const entry = index.projects.find((p) => p.id === projectId);
+ if (entry) {
+ entry.status = "active";
+ entry.updatedAt = now;
+ await this.saveIndex(index);
+ }
+
+ return { ...updated, status: "active", updatedAt: now };
+ }
+
/**
* Delete a project.
* For regular projects the internal directory is removed.
* For imported projects the source directory is NOT deleted
* (the user owns it) — only the index entry is removed.
*/
+ /**
+ * Resolve the migrations directory for a database.
+ * Moves legacy migrations from AppData to the project directory if they exist.
+ */
+ async resolveMigrationsDir(dbId: string): Promise {
+ const { CONFIG_FOLDER } = await import("../utils/config");
+ const fallbackDir = path.join(CONFIG_FOLDER, "migrations", dbId);
+ let targetDir = fallbackDir;
+
+ const project = await this.getProjectByDatabaseId(dbId);
+ if (project) {
+ targetDir = path.join(this.projectDir(project.id), "migrations");
+ }
+
+ // Migrate existing directory
+ if (targetDir !== fallbackDir && fsSync.existsSync(fallbackDir)) {
+ if (!fsSync.existsSync(targetDir)) {
+ await fs.mkdir(path.dirname(targetDir), { recursive: true }).catch(() => {});
+ try {
+ await fs.rename(fallbackDir, targetDir);
+ } catch (e) {
+ console.error("Failed to migrate existing migrations dir:", e);
+ }
+ }
+ }
+
+ if (!fsSync.existsSync(targetDir)) {
+ await fs.mkdir(targetDir, { recursive: true }).catch(() => {});
+ }
+
+ return targetDir;
+ }
+
+ async analyzeImportedProject(projectId: string): Promise<{ hasLock: boolean; hashMatch: boolean; migrationsCount: number }> {
+ const schemaFile = await this.getSchema(projectId);
+ const schemaHash = (schemaFile as any)?.schemaHash || "";
+
+ // Read lock file
+ let hasLock = false;
+ let hashMatch = false;
+ let migrationsCount = 0;
+
+ try {
+ const project = await this.getProject(projectId);
+ if (project?.databaseId) {
+ const { readMigrationLock } = await import("./migrationLock");
+ const lock = await readMigrationLock(project.databaseId);
+ if (lock) {
+ hasLock = true;
+ hashMatch = lock.schemaHash === schemaHash;
+ migrationsCount = lock.appliedMigrations.length;
+ }
+ }
+ } catch (e) {
+ // Ignore if lock file logic fails
+ }
+
+ return { hasLock, hashMatch, migrationsCount };
+ }
+
async deleteProject(projectId: string): Promise {
await this.ensureSourcePathCache();
const isImported = this.sourcePathCache.has(projectId);
@@ -591,34 +798,107 @@ export class ProjectStore {
await this.saveIndex(index);
}
+ /**
+ * Used by the connection deletion flow when user opts to "Delete project as well".
+ * Finds the project linked to the databaseId, deletes its folder, and removes from the store.
+ */
+ async deleteProjectByDatabaseId(databaseId: string): Promise {
+ const project = await this.getProjectByDatabaseId(databaseId);
+ if (!project) throw new Error("No linked project found");
+
+ await this.ensureSourcePathCache();
+ const isImported = this.sourcePathCache.has(project.id);
+
+ if (!isImported) {
+ // Transaction-like filesystem deletion first
+ const dir = this.projectDir(project.id);
+ if (fsSync.existsSync(dir)) {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+ }
+
+ // Remove from index + cache
+ this.sourcePathCache.delete(project.id);
+ const index = await this.loadIndex();
+ index.projects = index.projects.filter((p) => p.id !== project.id);
+ await this.saveIndex(index);
+ }
+
async getSchema(projectId: string): Promise {
- return this.readJSON(
+ const file = await this.readJSON(
this.projectFile(projectId, PROJECT_FILES.schema)
);
+ if (!file) return null;
+ return this.migrateSchemaFile(file);
}
- async saveSchema(projectId: string, schemas: SchemaSnapshot[]): Promise {
+ private migrateSchemaFile(raw: any): SchemaFile {
+ if (raw.version >= 2) return raw as SchemaFile;
+
+ // Upgrade v1 to v2
+ return {
+ version: 2,
+ projectId: raw.projectId,
+ databaseId: raw.databaseId,
+ dialect: "postgresql", // assume postgresql for legacy files
+ cachedAt: raw.cachedAt || new Date().toISOString(),
+ relwaveVersion: "1.0.0",
+ schemaHash: "", // will be recomputed on next refresh
+ schemas: (raw.schemas || []).map((s: any) => ({
+ name: s.name,
+ tables: (s.tables || []).map((t: any) => ({
+ name: t.name,
+ type: t.type || "BASE TABLE",
+ comment: null,
+ columns: (t.columns || []).map((c: any, index: number) => {
+ const isSerial = c.defaultValue ? c.defaultValue.includes("nextval") : false;
+ return {
+ ...c,
+ isSerial,
+ ordinalPosition: index + 1,
+ };
+ }),
+ indexes: [],
+ foreignKeys: [],
+ checks: [],
+ })),
+ enums: [],
+ views: [],
+ })),
+ };
+ }
+
+ async saveSchema(
+ projectId: string,
+ schemas: SchemaSnapshot[],
+ schemaHash?: string,
+ dialect?: string
+ ): Promise {
const meta = await this.getProject(projectId);
if (!meta) throw new Error(`Project ${projectId} not found`);
// Read existing file and skip write if schema data is identical
// (avoids cachedAt churn that creates phantom git changes)
const existing = await this.getSchema(projectId);
- if (existing) {
+ if (existing && existing.version === 2) {
const oldData = JSON.stringify(existing.schemas);
const newData = JSON.stringify(schemas);
- if (oldData === newData) {
+ // If schemaHash is provided, also check if it matches
+ if (oldData === newData && (!schemaHash || existing.schemaHash === schemaHash)) {
return existing; // nothing changed — keep old cachedAt
}
}
const now = new Date().toISOString();
const file: SchemaFile = {
- version: 1,
+ version: 2,
projectId,
databaseId: meta.databaseId,
+ dialect: (dialect || (meta.engine === "postgresql" ? "postgresql" : (meta.engine === "mysql" || meta.engine === "mariadb") ? "mysql" : "sqlite")) as any,
schemas,
cachedAt: now,
+ relwaveVersion: "1.0.0",
+ schemaHash: schemaHash || "",
};
await this.writeJSON(
@@ -1222,7 +1502,7 @@ export class ProjectStore {
// ---- 3. Resolve engine from the linked database ----
let engine: string | undefined;
try {
- const db: DBMeta | null = await dbStoreInstance.getDB(databaseId);
+ const db: DBMeta | undefined = await dbStoreInstance.getDB(databaseId);
engine = db?.type;
} catch {
// db may not exist yet
diff --git a/bridge/src/services/queryExecutor.ts b/bridge/src/services/queryExecutor.ts
index e098e61..425d58a 100644
--- a/bridge/src/services/queryExecutor.ts
+++ b/bridge/src/services/queryExecutor.ts
@@ -39,7 +39,12 @@ function mapColumn(col: any) {
isPrimaryKey: col.is_primary_key === true,
isForeignKey: col.is_foreign_key === true,
defaultValue: col.default_value || null,
- isUnique: false,
+ isUnique: col.is_unique === true,
+ isSerial: col.is_serial === true,
+ checkConstraint: col.check_constraint || null,
+ comment: col.comment || null,
+ maxLength: col.max_length || null,
+ ordinalPosition: col.ordinal_position || 0,
};
}
diff --git a/bridge/src/types/README.md b/bridge/src/types/README.md
new file mode 100644
index 0000000..5722e12
--- /dev/null
+++ b/bridge/src/types/README.md
@@ -0,0 +1,28 @@
+# Types (Bridge)
+
+This folder contains TypeScript types shared across bridge handlers, services, connectors and query result mapping.
+
+Layout
+- `index.ts` - central export plus `DBType`, `Rpc`, `DatabaseConfig` and `QueryParams`.
+- `common.ts` - shared database metadata shapes such as tables, columns, keys, indexes, constraints, stats, migrations and SSH config.
+- `postgres.ts` - PostgreSQL config and PostgreSQL-specific metadata/DDL types.
+- `mysql.ts` - MySQL and MariaDB config and metadata types.
+- `sqlite.ts` - SQLite config and metadata types.
+- `cache.ts` - cache entry and TTL types used by connector caches.
+- `ai.ts` - AI provider, prompt, result, history and error types.
+
+How it fits
+- Connectors use these types for database-specific configs and normalized result shapes.
+- Services use these types to avoid duplicating payload contracts.
+- Handlers should return data that matches these types where possible, then frontend bridge services mirror the same shape.
+
+How to add or change types
+1. Prefer extending `common.ts` when a shape applies across database engines.
+2. Use database-specific files only for engine-specific fields or operations.
+3. Re-export new public types from `index.ts`.
+4. Update connector and frontend types together when changing RPC response shapes.
+
+Notes
+- Keep these files type-only. Runtime helpers belong in `src/utils` or `src/services`.
+- Avoid widening everything to `any`; use explicit optional fields when a database can omit metadata.
+- Be careful with credential-bearing types. Public response shapes should not include plaintext passwords.
diff --git a/bridge/src/types/ai.ts b/bridge/src/types/ai.ts
new file mode 100644
index 0000000..7246326
--- /dev/null
+++ b/bridge/src/types/ai.ts
@@ -0,0 +1,96 @@
+// ── Shared types for the AI integration layer (moved from src/ai/ai.types.ts)
+
+export type AIProviderName =
+ | "anthropic"
+ | "openai"
+ | "gemini"
+ | "groq"
+ | "mistral"
+ | "ollama";
+
+/**
+ * User-facing AI settings — stored client-side and passed on every RPC call.
+ * The bridge is stateless regarding API keys.
+ */
+export interface AISettings {
+ defaultProvider: AIProviderName;
+ anthropicApiKey?: string;
+ openaiApiKey?: string;
+ geminiApiKey?: string;
+ groqApiKey?: string;
+ mistralApiKey?: string;
+ ollamaBaseUrl?: string;
+ ollamaModel?: string;
+}
+
+// ── Feature input/output types ────────────────────────────────────────────
+
+export interface SchemaColumn {
+ name: string;
+ type: string;
+ nullable?: boolean;
+ isPrimaryKey?: boolean;
+ isForeignKey?: boolean;
+ references?: { table: string; column: string };
+}
+
+export interface SchemaTable {
+ name: string;
+ schema?: string;
+ columns: SchemaColumn[];
+ indexes?: string[];
+ foreignKeys?: string[];
+ constraints?: string[];
+}
+
+export interface SchemaAnalysisInput {
+ tables: SchemaTable[];
+ databaseType?: string;
+}
+
+export interface QueryExplanationInput {
+ sql: string;
+ schema?: SchemaTable[];
+ databaseType?: string;
+}
+
+export interface ChartRecommendationInput {
+ tableName: string;
+ columns: Array<{
+ name: string;
+ type: string;
+ isPrimaryKey?: boolean;
+ sampleValues?: string[];
+ }>;
+}
+
+export interface ChartRecommendation {
+ chartType: "bar" | "line" | "area" | "pie";
+ xAxis: string;
+ yAxis: string;
+ reasoning: string;
+}
+
+// ── RPC param shapes ─────────────────────────────────────────────────────
+
+export interface AIRequestBase {
+ settings: AISettings;
+ /** Human-readable database / datasource name for history display. */
+ datasourceName?: string;
+ /** Table name context (e.g. for chart recommendation). */
+ tableName?: string;
+}
+
+export interface AIAnalyzeSchemaParams extends AIRequestBase {
+ input: SchemaAnalysisInput;
+}
+
+export interface AIExplainQueryParams extends AIRequestBase {
+ input: QueryExplanationInput;
+}
+
+export interface AIRecommendChartParams extends AIRequestBase {
+ input: ChartRecommendationInput;
+}
+
+export interface AITestConnectionParams extends AIRequestBase {}
diff --git a/bridge/src/types/common.ts b/bridge/src/types/common.ts
index 79d6aaf..599a834 100644
--- a/bridge/src/types/common.ts
+++ b/bridge/src/types/common.ts
@@ -47,6 +47,10 @@ export type ColumnDetail = {
is_foreign_key: boolean;
ordinal_position?: number;
max_length?: number | null;
+ is_unique?: boolean;
+ is_serial?: boolean;
+ check_constraint?: string;
+ comment?: string | null;
};
/**
diff --git a/bridge/src/types/index.ts b/bridge/src/types/index.ts
index 8c5e236..dcfa1e9 100644
--- a/bridge/src/types/index.ts
+++ b/bridge/src/types/index.ts
@@ -14,6 +14,7 @@ export * from './common';
export * from './mysql';
export * from './postgres';
export * from './sqlite';
+export * from './ai';
export { SSHConfig } from './common';
export enum DBType {
diff --git a/bridge/src/utils/README.md b/bridge/src/utils/README.md
new file mode 100644
index 0000000..d5a2392
--- /dev/null
+++ b/bridge/src/utils/README.md
@@ -0,0 +1,27 @@
+# Utilities (Bridge)
+
+This folder contains small, reusable bridge helpers for paths, database type detection, migration file handling and configuration.
+
+Layout
+- `config.ts` - RelWave data directories and file paths. Honors `RELWAVE_HOME` when set.
+- `dbTypeDetector.ts` - maps saved database records to `DBType`.
+- `sqlitePath.ts` - SQLite path normalization and resolution helpers.
+- `migrationGenerator.ts` - SQL migration file generation for create, alter and drop flows.
+- `migrationFileReader.ts` - migration file parsing/reading helpers.
+- `baselineMigration.ts` - local baseline migration loading and writing helpers.
+
+How it fits
+- Services and connectors use these helpers for filesystem paths, database type decisions and migration workflows.
+- `config.ts` is the source of truth for bridge persistence locations, including databases, projects, migrations and connection folders.
+- Migration utilities are used by migration handlers and connectors to generate, read and reconcile local migration files.
+
+How to add a utility
+1. Add utilities here only when they are reusable and do not own business state.
+2. Keep functions deterministic where possible and pass dependencies in as parameters.
+3. Use `config.ts` path helpers for filesystem locations instead of hardcoding OS-specific paths.
+4. Add tests for parsing, path resolution or SQL generation behavior that can regress quietly.
+
+Notes
+- Utilities should not send JSON-RPC responses or import frontend code.
+- Keep database-specific SQL in `src/queries`; use utility helpers for generation/parsing logic that is shared by workflows.
+- Avoid storing secrets in files managed by utility paths. Credentials should go through `keyringService`.
diff --git a/bridge/src/utils/baselineMigration.ts b/bridge/src/utils/baselineMigration.ts
index 03d3867..291363e 100644
--- a/bridge/src/utils/baselineMigration.ts
+++ b/bridge/src/utils/baselineMigration.ts
@@ -1,14 +1,124 @@
import fs from "fs";
import path from "path";
-function generateBaselineSQL(version: string, name: string) {
- return `-- ${version}_${name}.sql
+import { SchemaFile } from "../services/projectStore";
+
+function quoteIdent(name: string, dbType: string): string {
+ if (dbType === "mysql" || dbType === "mariadb") {
+ return `\`${name.replace(/`/g, "``")}\``;
+ } else {
+ return `"${name.replace(/"/g, '""')}"`;
+ }
+}
+
+export function generateBaselineSQL(snapshot: SchemaFile, version: string, name: string) {
+ const dbType = snapshot.dialect || "unknown";
+
+ if (dbType === "unknown") {
+ return `-- ${version}_${name}.sql
-- +up
-- Baseline migration
-- Existing schema assumed to be correct
-- No-op
+-- +down
+-- Rollback not supported for baseline
+`;
+ }
+
+ let upSQL = "";
+
+ for (const schema of snapshot.schemas) {
+ // Enums (Postgres)
+ if (schema.enums && dbType === "postgresql") {
+ for (const e of schema.enums) {
+ const vals = e.values.map(v => `'${v.replace(/'/g, "''")}'`).join(", ");
+ upSQL += `CREATE TYPE ${quoteIdent(schema.name, dbType)}.${quoteIdent(e.name, dbType)} AS ENUM (${vals});\n`;
+ }
+ if (schema.enums.length > 0) upSQL += "\n";
+ }
+
+ // Tables
+ for (const table of schema.tables) {
+ // Skip internal migration tracking tables
+ if (["schema_migrations", "__relwave_migrations", "relwave_migrations"].includes(table.name)) {
+ continue;
+ }
+
+ const tableRef = dbType === "sqlite" ? quoteIdent(table.name, dbType) : `${quoteIdent(schema.name, dbType)}.${quoteIdent(table.name, dbType)}`;
+
+ // Check for sequences first (Postgres)
+ if (dbType === "postgresql") {
+ for (const col of table.columns) {
+ if (col.defaultValue && col.defaultValue.includes("nextval(")) {
+ // Extract 'windows_store_apps_id_seq' from nextval('windows_store_apps_id_seq'::regclass)
+ const seqMatch = col.defaultValue.match(/nextval\('([^']+)'/i) || col.defaultValue.match(/nextval\("([^"]+)"/i);
+ if (seqMatch && seqMatch[1]) {
+ const seqName = seqMatch[1];
+ const isSchemaQualified = seqName.includes(".");
+ const seqRef = isSchemaQualified ? seqName : `${quoteIdent(schema.name, dbType)}.${quoteIdent(seqName, dbType)}`;
+ upSQL += `CREATE SEQUENCE IF NOT EXISTS ${seqRef};\n`;
+ }
+ }
+ }
+ }
+
+ const columnDefs = table.columns.map(col => {
+ let def = ` ${quoteIdent(col.name, dbType)} ${col.type}`;
+ if (!col.nullable) def += " NOT NULL";
+ if (col.defaultValue) def += ` DEFAULT ${col.defaultValue}`;
+ if (col.isPrimaryKey) def += " PRIMARY KEY";
+ if (col.isUnique && !col.isPrimaryKey) def += " UNIQUE";
+ if (col.checkConstraint) def += ` ${col.checkConstraint}`;
+ return def;
+ });
+
+ // check constraints
+ if (table.checks && table.checks.length > 0) {
+ for (const chk of table.checks) {
+ columnDefs.push(` CONSTRAINT ${quoteIdent(chk.name, dbType)} CHECK (${chk.expression})`);
+ }
+ }
+
+ const fkDefs = (table.foreignKeys || []).map(fk => {
+ const onDelete = fk.onDelete || "NO ACTION";
+ const onUpdate = fk.onUpdate || "NO ACTION";
+ const targetRef = dbType === "sqlite" ? quoteIdent(fk.referencedTable, dbType) : `${quoteIdent(fk.referencedSchema || schema.name, dbType)}.${quoteIdent(fk.referencedTable, dbType)}`;
+ const srcCols = fk.columns.map(c => quoteIdent(c, dbType)).join(", ");
+ const tgtCols = fk.referencedColumns.map(c => quoteIdent(c, dbType)).join(", ");
+ return ` CONSTRAINT ${quoteIdent(fk.name, dbType)} FOREIGN KEY (${srcCols}) REFERENCES ${targetRef}(${tgtCols}) ON DELETE ${onDelete} ON UPDATE ${onUpdate}`;
+ });
+
+ const allDefs = [...columnDefs, ...fkDefs].join(",\n");
+
+ upSQL += `CREATE TABLE ${tableRef} (\n${allDefs}\n);\n\n`;
+
+ // Indexes
+ const indexesToCreate = (table.indexes || []);
+ for (const idx of indexesToCreate) {
+ const idxCols = idx.columns.map(c => quoteIdent(c, dbType)).join(", ");
+ const uniqueStr = idx.unique ? "UNIQUE " : "";
+ upSQL += `CREATE ${uniqueStr}INDEX ${quoteIdent(idx.name, dbType)} ON ${tableRef} (${idxCols});\n`;
+ }
+ if (indexesToCreate.length > 0) upSQL += "\n";
+ }
+
+ // Views
+ if (schema.views) {
+ for (const v of schema.views) {
+ const viewRef = dbType === "sqlite" ? quoteIdent(v.name, dbType) : `${quoteIdent(schema.name, dbType)}.${quoteIdent(v.name, dbType)}`;
+ upSQL += `CREATE VIEW ${viewRef} AS ${v.definition};\n\n`;
+ }
+ }
+ }
+
+ return `-- ${version}_${name}.sql
+
+-- +up
+-- Baseline migration generated from current schema snapshot
+${upSQL.trim()}
+
-- +down
-- Rollback not supported for baseline
`;
@@ -18,7 +128,8 @@ function generateBaselineSQL(version: string, name: string) {
export function writeBaselineMigration(
migrationsDir: string,
version: string,
- name: string
+ name: string,
+ snapshot: SchemaFile
) {
if (!fs.existsSync(migrationsDir)) {
fs.mkdirSync(migrationsDir, { recursive: true });
@@ -27,7 +138,7 @@ export function writeBaselineMigration(
const filename = `${version}_${name}.sql`;
const filepath = path.join(migrationsDir, filename);
- const sql = generateBaselineSQL(version, name);
+ const sql = generateBaselineSQL(snapshot, version, name);
fs.writeFileSync(filepath, sql, "utf8");
return filepath;
diff --git a/bridge/src/utils/config.ts b/bridge/src/utils/config.ts
index c1435ec..f650257 100644
--- a/bridge/src/utils/config.ts
+++ b/bridge/src/utils/config.ts
@@ -22,10 +22,6 @@ export function getConnectionDir(connectionId: string) {
return path.join(CONFIG_FOLDER, "connections", connectionId);
}
-export function getMigrationsDir(connectionId: string) {
- return path.join(CONFIG_FOLDER, "migrations", connectionId);
-}
-
export function getProjectDir(projectId: string) {
return path.join(PROJECTS_FOLDER, projectId);
}
diff --git a/bridge/src/utils/migrationFileReader.ts b/bridge/src/utils/migrationFileReader.ts
index ac5a07e..88627fc 100644
--- a/bridge/src/utils/migrationFileReader.ts
+++ b/bridge/src/utils/migrationFileReader.ts
@@ -17,8 +17,8 @@ export function readMigrationFile(filepath: string): ParsedMigration {
const content = fs.readFileSync(filepath, "utf8");
const filename = path.basename(filepath);
- // Extract version and name from filename: 20260104160000_create_users.sql
- const match = filename.match(/^(\d{14})_(.+)\.sql$/);
+ // Accept both 14-digit (YYYYMMDDHHmmss) and 13-digit (Date.now() ms) version formats
+ const match = filename.match(/^(\d{13,14})_(.+)\.sql$/);
if (!match) {
throw new Error(`Invalid migration filename format: ${filename}`);
}
diff --git a/index.html b/index.html
index 71d3362..84ec270 100644
--- a/index.html
+++ b/index.html
@@ -1,14 +1,17 @@
-
+
-
-
-
-
-
+
+
+
+
+
RelWave
diff --git a/package.json b/package.json
index 755d04c..7b50937 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "relwave",
"private": true,
- "version": "0.7.0-beta-1",
+ "version": " 0.9.0-rc-1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -53,6 +53,7 @@
"radix-ui": "^1.4.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+ "react-markdown": "^10.1.0",
"react-router-dom": "^7.9.6",
"reactflow": "^11.11.4",
"recharts": "^2.15.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9048824..8d58629 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -128,6 +128,9 @@ importers:
react-dom:
specifier: ^19.1.0
version: 19.2.3(react@19.2.3)
+ react-markdown:
+ specifier: ^10.1.0
+ version: 10.1.0(@types/react@19.2.8)(react@19.2.3)
react-router-dom:
specifier: ^7.9.6
version: 7.9.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -1944,21 +1947,36 @@ packages:
'@types/dagre@0.7.53':
resolution: {integrity: sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==}
+ '@types/debug@4.1.13':
+ resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
+
+ '@types/estree-jsx@1.0.5':
+ resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
+
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
+ '@types/hast@3.0.4':
+ resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
+
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
+ '@types/mdast@4.0.4':
+ resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
+ '@types/ms@2.1.0':
+ resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
'@types/node@24.12.0':
resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==}
@@ -1973,6 +1991,12 @@ packages:
'@types/stylis@4.2.5':
resolution: {integrity: sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==}
+ '@types/unist@2.0.11':
+ resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
+
+ '@types/unist@3.0.3':
+ resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
@@ -1998,6 +2022,9 @@ packages:
react: '>=17.0.0'
react-dom: '>=17.0.0'
+ '@ungap/structured-clone@1.3.1':
+ resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
+
'@use-gesture/core@10.3.1':
resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==}
@@ -2027,6 +2054,9 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
+ bail@2.0.2:
+ resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
+
baseline-browser-mapping@2.9.19:
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
hasBin: true
@@ -2042,10 +2072,25 @@ packages:
caniuse-lite@1.0.30001756:
resolution: {integrity: sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==}
+ ccount@2.0.1:
+ resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
+ character-entities-html4@2.1.0:
+ resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
+
+ character-entities-legacy@3.0.0:
+ resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
+
+ character-entities@2.0.2:
+ resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
+
+ character-reference-invalid@2.0.1:
+ resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
+
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -2079,6 +2124,9 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+ comma-separated-tokens@2.0.3:
+ resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
+
concurrently@9.2.1:
resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==}
engines: {node: '>=18'}
@@ -2191,6 +2239,13 @@ packages:
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
+ decode-named-character-reference@1.3.0:
+ resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
+
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -2198,6 +2253,9 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+ devlop@1.1.0:
+ resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
@@ -2228,9 +2286,15 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
+ estree-util-is-identifier-name@3.0.0:
+ resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
+
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
+ extend@3.0.2:
+ resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+
fast-equals@5.4.0:
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
engines: {node: '>=6.0.0'}
@@ -2275,23 +2339,51 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
+ hast-util-to-jsx-runtime@2.3.6:
+ resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
+
+ hast-util-whitespace@3.0.0:
+ resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
+
hotkeys-js@3.13.15:
resolution: {integrity: sha512-gHh8a/cPTCpanraePpjRxyIlxDFrIhYqjuh01UHWEwDpglJKCnvLW8kqSx5gQtOuSsJogNZXLhOdbSExpgUiqg==}
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
+ html-url-attributes@3.0.1:
+ resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
+
idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
+ inline-style-parser@0.2.7:
+ resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
+
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
+ is-alphabetical@2.0.1:
+ resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
+
+ is-alphanumerical@2.0.1:
+ resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
+
+ is-decimal@2.0.1:
+ resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
+
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
+ is-hexadecimal@2.0.1:
+ resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
+
+ is-plain-obj@4.1.0:
+ resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
+ engines: {node: '>=12'}
+
is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
@@ -2413,6 +2505,9 @@ packages:
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
+ longest-streak@3.1.0:
+ resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@@ -2436,9 +2531,96 @@ packages:
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
hasBin: true
+ mdast-util-from-markdown@2.0.3:
+ resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==}
+
+ mdast-util-mdx-expression@2.0.1:
+ resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
+
+ mdast-util-mdx-jsx@3.2.0:
+ resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
+
+ mdast-util-mdxjs-esm@2.0.1:
+ resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
+
+ mdast-util-phrasing@4.1.0:
+ resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
+
+ mdast-util-to-hast@13.2.1:
+ resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
+
+ mdast-util-to-markdown@2.1.2:
+ resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
+
+ mdast-util-to-string@4.0.0:
+ resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
+ micromark-core-commonmark@2.0.3:
+ resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
+
+ micromark-factory-destination@2.0.1:
+ resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
+
+ micromark-factory-label@2.0.1:
+ resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
+
+ micromark-factory-space@2.0.1:
+ resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==}
+
+ micromark-factory-title@2.0.1:
+ resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
+
+ micromark-factory-whitespace@2.0.1:
+ resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
+
+ micromark-util-character@2.1.1:
+ resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
+
+ micromark-util-chunked@2.0.1:
+ resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
+
+ micromark-util-classify-character@2.0.1:
+ resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
+
+ micromark-util-combine-extensions@2.0.1:
+ resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
+
+ micromark-util-decode-string@2.0.1:
+ resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==}
+
+ micromark-util-encode@2.0.1:
+ resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
+
+ micromark-util-html-tag-name@2.0.1:
+ resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
+
+ micromark-util-normalize-identifier@2.0.1:
+ resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
+
+ micromark-util-resolve-all@2.0.1:
+ resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
+
+ micromark-util-sanitize-uri@2.0.1:
+ resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
+
+ micromark-util-subtokenize@2.1.0:
+ resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
+
+ micromark-util-symbol@2.0.1:
+ resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
+
+ micromark-util-types@2.0.2:
+ resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
+
+ micromark@4.0.2:
+ resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2463,6 +2645,9 @@ packages:
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
+ parse-entities@4.0.2:
+ resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2484,6 +2669,9 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+ property-information@7.2.0:
+ resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
+
prosemirror-changeset@2.4.0:
resolution: {integrity: sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==}
@@ -2576,6 +2764,12 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
+ react-markdown@10.1.0:
+ resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
+ peerDependencies:
+ '@types/react': '>=18'
+ react: '>=18'
+
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -2659,6 +2853,12 @@ packages:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ remark-parse@11.0.0:
+ resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
+
+ remark-rehype@11.1.2:
+ resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
+
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
@@ -2701,10 +2901,16 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ space-separated-tokens@2.0.2:
+ resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
+ stringify-entities@4.0.4:
+ resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
+
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@@ -2712,6 +2918,12 @@ packages:
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
+ style-to-js@1.1.21:
+ resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
+
+ style-to-object@1.0.14:
+ resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
+
styled-components@6.1.19:
resolution: {integrity: sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==}
engines: {node: '>= 16'}
@@ -2757,6 +2969,12 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
+ trim-lines@3.0.1:
+ resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
+
+ trough@2.2.0:
+ resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
+
tslib@2.6.2:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
@@ -2777,6 +2995,24 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+ unified@11.0.5:
+ resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
+
+ unist-util-is@6.0.1:
+ resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
+
+ unist-util-position@5.0.0:
+ resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
+
+ unist-util-stringify-position@4.0.0:
+ resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
+
+ unist-util-visit-parents@6.0.2:
+ resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
+
+ unist-util-visit@5.1.0:
+ resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
+
update-browserslist-db@1.1.4:
resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==}
hasBin: true
@@ -2808,6 +3044,12 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ vfile-message@4.0.3:
+ resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
+
+ vfile@6.0.3:
+ resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+
victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
@@ -2888,6 +3130,9 @@ packages:
react:
optional: true
+ zwitch@2.0.4:
+ resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+
snapshots:
'@babel/code-frame@7.27.1':
@@ -4693,10 +4938,22 @@ snapshots:
'@types/dagre@0.7.53': {}
+ '@types/debug@4.1.13':
+ dependencies:
+ '@types/ms': 2.1.0
+
+ '@types/estree-jsx@1.0.5':
+ dependencies:
+ '@types/estree': 1.0.8
+
'@types/estree@1.0.8': {}
'@types/geojson@7946.0.16': {}
+ '@types/hast@3.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
'@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.1.2':
@@ -4704,8 +4961,14 @@ snapshots:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
+ '@types/mdast@4.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
'@types/mdurl@2.0.0': {}
+ '@types/ms@2.1.0': {}
+
'@types/node@24.12.0':
dependencies:
undici-types: 7.16.0
@@ -4720,6 +4983,10 @@ snapshots:
'@types/stylis@4.2.5': {}
+ '@types/unist@2.0.11': {}
+
+ '@types/unist@3.0.3': {}
+
'@types/use-sync-external-store@0.0.6': {}
'@uiw/codemirror-extensions-basic-setup@4.25.4(@codemirror/autocomplete@6.20.1)(@codemirror/commands@6.10.2)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)':
@@ -4749,6 +5016,8 @@ snapshots:
- '@codemirror/lint'
- '@codemirror/search'
+ '@ungap/structured-clone@1.3.1': {}
+
'@use-gesture/core@10.3.1': {}
'@use-gesture/react@10.3.1(react@19.2.3)':
@@ -4780,6 +5049,8 @@ snapshots:
dependencies:
tslib: 2.8.1
+ bail@2.0.2: {}
+
baseline-browser-mapping@2.9.19: {}
browserslist@4.28.0:
@@ -4794,11 +5065,21 @@ snapshots:
caniuse-lite@1.0.30001756: {}
+ ccount@2.0.1: {}
+
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
+ character-entities-html4@2.1.0: {}
+
+ character-entities-legacy@3.0.0: {}
+
+ character-entities@2.0.2: {}
+
+ character-reference-invalid@2.0.1: {}
+
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
@@ -4843,6 +5124,8 @@ snapshots:
color-name@1.1.4: {}
+ comma-separated-tokens@2.0.3: {}
+
concurrently@9.2.1:
dependencies:
chalk: 4.1.2
@@ -4947,10 +5230,20 @@ snapshots:
decimal.js-light@2.5.1: {}
+ decode-named-character-reference@1.3.0:
+ dependencies:
+ character-entities: 2.0.2
+
+ dequal@2.0.3: {}
+
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
+ devlop@1.1.0:
+ dependencies:
+ dequal: 2.0.3
+
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.28.4
@@ -5000,8 +5293,12 @@ snapshots:
escape-string-regexp@4.0.0: {}
+ estree-util-is-identifier-name@3.0.0: {}
+
eventemitter3@4.0.7: {}
+ extend@3.0.2: {}
+
fast-equals@5.4.0: {}
fdir@6.5.0(picomatch@4.0.3):
@@ -5027,16 +5324,57 @@ snapshots:
has-flag@4.0.0: {}
+ hast-util-to-jsx-runtime@2.3.6:
+ dependencies:
+ '@types/estree': 1.0.8
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ comma-separated-tokens: 2.0.3
+ devlop: 1.1.0
+ estree-util-is-identifier-name: 3.0.0
+ hast-util-whitespace: 3.0.0
+ mdast-util-mdx-expression: 2.0.1
+ mdast-util-mdx-jsx: 3.2.0
+ mdast-util-mdxjs-esm: 2.0.1
+ property-information: 7.2.0
+ space-separated-tokens: 2.0.2
+ style-to-js: 1.1.21
+ unist-util-position: 5.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ hast-util-whitespace@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
hotkeys-js@3.13.15: {}
html-to-image@1.11.13: {}
+ html-url-attributes@3.0.1: {}
+
idb@7.1.1: {}
+ inline-style-parser@0.2.7: {}
+
internmap@2.0.3: {}
+ is-alphabetical@2.0.1: {}
+
+ is-alphanumerical@2.0.1:
+ dependencies:
+ is-alphabetical: 2.0.1
+ is-decimal: 2.0.1
+
+ is-decimal@2.0.1: {}
+
is-fullwidth-code-point@3.0.0: {}
+ is-hexadecimal@2.0.1: {}
+
+ is-plain-obj@4.1.0: {}
+
is-plain-object@5.0.0: {}
jiti@2.6.1: {}
@@ -5116,6 +5454,8 @@ snapshots:
lodash@4.17.23: {}
+ longest-streak@3.1.0: {}
+
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@@ -5143,8 +5483,230 @@ snapshots:
punycode.js: 2.3.1
uc.micro: 2.1.0
+ mdast-util-from-markdown@2.0.3:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ mdast-util-to-string: 4.0.0
+ micromark: 4.0.2
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-decode-string: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ unist-util-stringify-position: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-expression@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-jsx@3.2.0:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ parse-entities: 4.0.2
+ stringify-entities: 4.0.4
+ unist-util-stringify-position: 4.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdxjs-esm@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-phrasing@4.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ unist-util-is: 6.0.1
+
+ mdast-util-to-hast@13.2.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@ungap/structured-clone': 1.3.1
+ devlop: 1.1.0
+ micromark-util-sanitize-uri: 2.0.1
+ trim-lines: 3.0.1
+ unist-util-position: 5.0.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+
+ mdast-util-to-markdown@2.1.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ longest-streak: 3.1.0
+ mdast-util-phrasing: 4.1.0
+ mdast-util-to-string: 4.0.0
+ micromark-util-classify-character: 2.0.1
+ micromark-util-decode-string: 2.0.1
+ unist-util-visit: 5.1.0
+ zwitch: 2.0.4
+
+ mdast-util-to-string@4.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+
mdurl@2.0.0: {}
+ micromark-core-commonmark@2.0.3:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-factory-destination: 2.0.1
+ micromark-factory-label: 2.0.1
+ micromark-factory-space: 2.0.1
+ micromark-factory-title: 2.0.1
+ micromark-factory-whitespace: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-html-tag-name: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-destination@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-label@2.0.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-space@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-title@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-whitespace@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-character@2.1.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-chunked@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-classify-character@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-combine-extensions@2.0.1:
+ dependencies:
+ micromark-util-chunked: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-decode-string@2.0.1:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ micromark-util-character: 2.1.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-encode@2.0.1: {}
+
+ micromark-util-html-tag-name@2.0.1: {}
+
+ micromark-util-normalize-identifier@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-resolve-all@2.0.1:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-util-sanitize-uri@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-encode: 2.0.1
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-subtokenize@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-symbol@2.0.1: {}
+
+ micromark-util-types@2.0.2: {}
+
+ micromark@4.0.2:
+ dependencies:
+ '@types/debug': 4.1.13
+ debug: 4.4.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-encode: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
ms@2.1.3: {}
nanoid@3.3.11: {}
@@ -5160,6 +5722,16 @@ snapshots:
orderedmap@2.1.1: {}
+ parse-entities@4.0.2:
+ dependencies:
+ '@types/unist': 2.0.11
+ character-entities-legacy: 3.0.0
+ character-reference-invalid: 2.0.1
+ decode-named-character-reference: 1.3.0
+ is-alphanumerical: 2.0.1
+ is-decimal: 2.0.1
+ is-hexadecimal: 2.0.1
+
picocolors@1.1.1: {}
picomatch@4.0.3: {}
@@ -5184,6 +5756,8 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
+ property-information@7.2.0: {}
+
prosemirror-changeset@2.4.0:
dependencies:
prosemirror-transform: 1.11.0
@@ -5367,6 +5941,24 @@ snapshots:
react-is@18.3.1: {}
+ react-markdown@10.1.0(@types/react@19.2.8)(react@19.2.3):
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@types/react': 19.2.8
+ devlop: 1.1.0
+ hast-util-to-jsx-runtime: 2.3.6
+ html-url-attributes: 3.0.1
+ mdast-util-to-hast: 13.2.1
+ react: 19.2.3
+ remark-parse: 11.0.0
+ remark-rehype: 11.1.2
+ unified: 11.0.5
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
react-refresh@0.17.0: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.8)(react@19.2.3):
@@ -5460,6 +6052,23 @@ snapshots:
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
+ remark-parse@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.3
+ micromark-util-types: 2.0.2
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-rehype@11.1.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ mdast-util-to-hast: 13.2.1
+ unified: 11.0.5
+ vfile: 6.0.3
+
require-directory@2.1.1: {}
rollup@4.59.0:
@@ -5516,18 +6125,33 @@ snapshots:
source-map-js@1.2.1: {}
+ space-separated-tokens@2.0.2: {}
+
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
+ stringify-entities@4.0.4:
+ dependencies:
+ character-entities-html4: 2.1.0
+ character-entities-legacy: 3.0.0
+
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
style-mod@4.1.3: {}
+ style-to-js@1.1.21:
+ dependencies:
+ style-to-object: 1.0.14
+
+ style-to-object@1.0.14:
+ dependencies:
+ inline-style-parser: 0.2.7
+
styled-components@6.1.19(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@emotion/is-prop-valid': 1.2.2
@@ -5590,6 +6214,10 @@ snapshots:
tree-kill@1.2.2: {}
+ trim-lines@3.0.1: {}
+
+ trough@2.2.0: {}
+
tslib@2.6.2: {}
tslib@2.8.1: {}
@@ -5602,6 +6230,39 @@ snapshots:
undici-types@7.16.0: {}
+ unified@11.0.5:
+ dependencies:
+ '@types/unist': 3.0.3
+ bail: 2.0.2
+ devlop: 1.1.0
+ extend: 3.0.2
+ is-plain-obj: 4.1.0
+ trough: 2.2.0
+ vfile: 6.0.3
+
+ unist-util-is@6.0.1:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-position@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-stringify-position@4.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-visit-parents@6.0.2:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
+ unist-util-visit@5.1.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
update-browserslist-db@1.1.4(browserslist@4.28.0):
dependencies:
browserslist: 4.28.0
@@ -5627,6 +6288,16 @@ snapshots:
dependencies:
react: 19.2.3
+ vfile-message@4.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-stringify-position: 4.0.0
+
+ vfile@6.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ vfile-message: 4.0.3
+
victory-vendor@36.9.2:
dependencies:
'@types/d3-array': 3.2.2
@@ -5688,3 +6359,5 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.8
react: 19.2.3
+
+ zwitch@2.0.4: {}
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index ed0def2..2b42f61 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -3073,7 +3073,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "relwave"
-version = "0.7.0-beta-1"
+version = " 0.9.0-rc-1"
dependencies = [
"serde",
"serde_json",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 64d5cec..3d3c9be 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "relwave"
-version = "0.7.0-beta-1"
+version = " 0.9.0-rc-1"
description = "A powerful, cross-platform desktop application for database management with native Git version control."
authors = ["yashh56"]
edition = "2021"
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index bc0ac2f..bebb698 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "RelWave",
- "version": "0.7.0-beta-1",
+ "version": " 0.9.0-rc-1",
"identifier": "tech.relwave.app",
"build": {
"beforeDevCommand": "vite",
diff --git a/src/App.css b/src/App.css
index 125d27c..8e5d813 100644
--- a/src/App.css
+++ b/src/App.css
@@ -3,10 +3,21 @@
@custom-variant dark (&:is(.dark *));
+.font-sans {
+ font-family: "IBM Plex Sans", sans-serif;
+}
+
+.font-mono {
+ font-family: "IBM Plex Mono", monospace;
+}
+
/* Font Family Configuration */
@theme inline {
- --font-sans: "Geist", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- --font-mono: "Geist Mono", "JetBrains Mono", "Fira Code", ui-monospace, monospace;
+ --font-sans:
+ "Geist", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ sans-serif;
+ --font-mono:
+ "Geist Mono", "JetBrains Mono", "Fira Code", ui-monospace, monospace;
}
@theme inline {
@@ -80,9 +91,10 @@
--sidebar-accent-foreground: oklch(0.22 0.02 252);
--sidebar-border: oklch(0.875 0.012 247);
--sidebar-ring: var(--ring);
-
+
/* New Design System Tokens */
- --card-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 12px 32px rgba(15, 23, 42, 0.05);
+ --card-shadow:
+ 0 1px 2px rgba(15, 23, 42, 0.06), 0 12px 32px rgba(15, 23, 42, 0.05);
--card-shadow-hover: 0 12px 36px rgba(15, 23, 42, 0.1);
--glass-bg: rgba(255, 255, 255, 0.78);
--glass-blur: 12px;
@@ -138,8 +150,10 @@ body,
--sidebar-accent-foreground: oklch(0.94 0.006 255);
--sidebar-border: oklch(1 0 0 / 9%);
--sidebar-ring: var(--ring);
- --card-shadow: 0 1px 0 rgba(255, 255, 255, 0.04) inset, 0 18px 52px rgba(0, 0, 0, 0.34);
- --card-shadow-hover: 0 1px 0 rgba(255, 255, 255, 0.06) inset, 0 22px 64px rgba(0, 0, 0, 0.42);
+ --card-shadow:
+ 0 1px 0 rgba(255, 255, 255, 0.04) inset, 0 18px 52px rgba(0, 0, 0, 0.34);
+ --card-shadow-hover:
+ 0 1px 0 rgba(255, 255, 255, 0.06) inset, 0 22px 64px rgba(0, 0, 0, 0.42);
--glass-bg: rgba(23, 29, 39, 0.76);
--surface: oklch(0.16 0.014 250);
--surface-elevated: oklch(0.19 0.016 250);
@@ -203,230 +217,242 @@ body,
tricks. */
[data-theme-variant="cyberpunk"],
.dark[data-theme-variant="cyberpunk"] {
- --background: #070b14;
- --foreground: #e2f0ff;
- --card: #0d1526;
- --card-foreground: #e2f0ff;
- --popover: #0d1526;
- --popover-foreground: #e2f0ff;
- --primary: #00f5ff;
- --primary-foreground: #030c12;
- --secondary: #0f1e35;
- --secondary-foreground: #a8cfef;
- --muted: #0d1a2e;
- --muted-foreground: #6a8faf;
- --accent: #ff00c8;
- --accent-foreground: #fff;
- --destructive: #ff3366;
- --border: #0e2540;
- --input: #0a1a2e;
- --ring: #00f5ff;
- --sidebar: #060d1c;
- --sidebar-foreground: #cde8ff;
- --sidebar-primary: #00f5ff;
+ --background: #070b14;
+ --foreground: #e2f0ff;
+ --card: #0d1526;
+ --card-foreground: #e2f0ff;
+ --popover: #0d1526;
+ --popover-foreground: #e2f0ff;
+ --primary: #00f5ff;
+ --primary-foreground: #030c12;
+ --secondary: #0f1e35;
+ --secondary-foreground: #a8cfef;
+ --muted: #0d1a2e;
+ --muted-foreground: #6a8faf;
+ --accent: #ff00c8;
+ --accent-foreground: #fff;
+ --destructive: #ff3366;
+ --border: #0e2540;
+ --input: #0a1a2e;
+ --ring: #00f5ff;
+ --sidebar: #060d1c;
+ --sidebar-foreground: #cde8ff;
+ --sidebar-primary: #00f5ff;
--sidebar-primary-foreground: #030c12;
- --sidebar-accent: #0f2240;
+ --sidebar-accent: #0f2240;
--sidebar-accent-foreground: #00f5ff;
- --sidebar-border: rgba(0,245,255,0.08);
- --sidebar-ring: #00f5ff;
- --card-shadow: 0 0 0 1px rgba(0,245,255,0.06), 0 8px 32px rgba(0,0,0,0.6);
- --card-shadow-hover: 0 0 0 1px rgba(0,245,255,0.14), 0 12px 44px rgba(0,0,0,0.7);
- --glass-bg: rgba(7,11,20,0.85);
- --surface: #0a1220;
- --surface-elevated: #0d1526;
- --surface-subtle: #0f1d32;
- --border-subtle: rgba(0,245,255,0.07);
+ --sidebar-border: rgba(0, 245, 255, 0.08);
+ --sidebar-ring: #00f5ff;
+ --card-shadow:
+ 0 0 0 1px rgba(0, 245, 255, 0.06), 0 8px 32px rgba(0, 0, 0, 0.6);
+ --card-shadow-hover:
+ 0 0 0 1px rgba(0, 245, 255, 0.14), 0 12px 44px rgba(0, 0, 0, 0.7);
+ --glass-bg: rgba(7, 11, 20, 0.85);
+ --surface: #0a1220;
+ --surface-elevated: #0d1526;
+ --surface-subtle: #0f1d32;
+ --border-subtle: rgba(0, 245, 255, 0.07);
}
/* Cyberpunk neon glow on focused/primary interactive elements */
-[data-theme-variant="cyberpunk"] button[data-slot="button"]:not([data-variant="ghost"]):not([data-variant="outline"]):focus-visible,
+[data-theme-variant="cyberpunk"]
+ button[data-slot="button"]:not([data-variant="ghost"]):not(
+ [data-variant="outline"]
+ ):focus-visible,
[data-theme-variant="cyberpunk"] [role="button"]:focus-visible {
- box-shadow: 0 0 0 2px #00f5ff, 0 0 12px rgba(0,245,255,0.4);
+ box-shadow:
+ 0 0 0 2px #00f5ff,
+ 0 0 12px rgba(0, 245, 255, 0.4);
}
/* ── Full-palette theme: VS Code ──────────────────────────────────────── */
[data-theme-variant="vscode"],
.dark[data-theme-variant="vscode"] {
- --background: #1e1e1e;
- --foreground: #d4d4d4;
- --card: #252526;
- --card-foreground: #d4d4d4;
- --popover: #2d2d30;
- --popover-foreground: #d4d4d4;
- --primary: #569cd6;
- --primary-foreground: #1e1e1e;
- --secondary: #2d2d30;
- --secondary-foreground: #cccccc;
- --muted: #333333;
- --muted-foreground: #808080;
- --accent: #264f78;
- --accent-foreground: #d4d4d4;
- --destructive: #f44747;
- --border: #3e3e42;
- --input: #3c3c3c;
- --ring: #569cd6;
- --sidebar: #252526;
- --sidebar-foreground: #bbbbbb;
- --sidebar-primary: #569cd6;
+ --background: #1e1e1e;
+ --foreground: #d4d4d4;
+ --card: #252526;
+ --card-foreground: #d4d4d4;
+ --popover: #2d2d30;
+ --popover-foreground: #d4d4d4;
+ --primary: #569cd6;
+ --primary-foreground: #1e1e1e;
+ --secondary: #2d2d30;
+ --secondary-foreground: #cccccc;
+ --muted: #333333;
+ --muted-foreground: #808080;
+ --accent: #264f78;
+ --accent-foreground: #d4d4d4;
+ --destructive: #f44747;
+ --border: #3e3e42;
+ --input: #3c3c3c;
+ --ring: #569cd6;
+ --sidebar: #252526;
+ --sidebar-foreground: #bbbbbb;
+ --sidebar-primary: #569cd6;
--sidebar-primary-foreground: #1e1e1e;
- --sidebar-accent: #37373d;
+ --sidebar-accent: #37373d;
--sidebar-accent-foreground: #ffffff;
- --sidebar-border: #3e3e42;
- --sidebar-ring: #569cd6;
- --card-shadow: 0 1px 0 rgba(255,255,255,0.04) inset, 0 8px 24px rgba(0,0,0,0.4);
- --card-shadow-hover: 0 1px 0 rgba(255,255,255,0.06) inset, 0 12px 32px rgba(0,0,0,0.5);
- --glass-bg: rgba(37,37,38,0.92);
- --surface: #252526;
- --surface-elevated: #2d2d30;
- --surface-subtle: #333333;
- --border-subtle: #3e3e42;
+ --sidebar-border: #3e3e42;
+ --sidebar-ring: #569cd6;
+ --card-shadow:
+ 0 1px 0 rgba(255, 255, 255, 0.04) inset, 0 8px 24px rgba(0, 0, 0, 0.4);
+ --card-shadow-hover:
+ 0 1px 0 rgba(255, 255, 255, 0.06) inset, 0 12px 32px rgba(0, 0, 0, 0.5);
+ --glass-bg: rgba(37, 37, 38, 0.92);
+ --surface: #252526;
+ --surface-elevated: #2d2d30;
+ --surface-subtle: #333333;
+ --border-subtle: #3e3e42;
}
/* ── Full-palette theme: Valorant ────────────────────────────────────── */
[data-theme-variant="valorant"] {
- --background: oklch(0.97 0.02 12.78);
- --foreground: oklch(0.24 0.07 17.81);
- --card: oklch(0.98 0.01 17.28);
- --card-foreground: oklch(0.26 0.07 19);
- --popover: oklch(0.98 0.01 17.28);
- --popover-foreground: oklch(0.26 0.07 19);
- --primary: oklch(0.67 0.22 21.34);
- --primary-foreground: oklch(0.99 0.00 359.99);
- --secondary: oklch(0.95 0.02 11.28);
- --secondary-foreground: oklch(0.24 0.07 17.81);
- --muted: oklch(0.98 0.01 17.28);
- --muted-foreground: oklch(0.55 0.08 19);
- --accent: oklch(0.99 0.00 359.99);
- --accent-foreground: oklch(0.43 0.13 20.62);
- --destructive: oklch(0.80 0.17 73.27);
- --border: oklch(0.91 0.05 11.40);
- --input: oklch(0.90 0.05 12.59);
- --ring: oklch(0.92 0.04 12.39);
- --sidebar: oklch(0.97 0.02 12.78);
- --sidebar-foreground: oklch(0.26 0.07 19);
- --sidebar-primary: oklch(0.67 0.22 21.34);
- --sidebar-primary-foreground: oklch(0.99 0.00 359.99);
- --sidebar-accent: oklch(0.98 0.01 17.28);
+ --background: oklch(0.97 0.02 12.78);
+ --foreground: oklch(0.24 0.07 17.81);
+ --card: oklch(0.98 0.01 17.28);
+ --card-foreground: oklch(0.26 0.07 19);
+ --popover: oklch(0.98 0.01 17.28);
+ --popover-foreground: oklch(0.26 0.07 19);
+ --primary: oklch(0.67 0.22 21.34);
+ --primary-foreground: oklch(0.99 0 359.99);
+ --secondary: oklch(0.95 0.02 11.28);
+ --secondary-foreground: oklch(0.24 0.07 17.81);
+ --muted: oklch(0.98 0.01 17.28);
+ --muted-foreground: oklch(0.55 0.08 19);
+ --accent: oklch(0.99 0 359.99);
+ --accent-foreground: oklch(0.43 0.13 20.62);
+ --destructive: oklch(0.8 0.17 73.27);
+ --border: oklch(0.91 0.05 11.4);
+ --input: oklch(0.9 0.05 12.59);
+ --ring: oklch(0.92 0.04 12.39);
+ --sidebar: oklch(0.97 0.02 12.78);
+ --sidebar-foreground: oklch(0.26 0.07 19);
+ --sidebar-primary: oklch(0.67 0.22 21.34);
+ --sidebar-primary-foreground: oklch(0.99 0 359.99);
+ --sidebar-accent: oklch(0.98 0.01 17.28);
--sidebar-accent-foreground: oklch(0.43 0.13 20.62);
- --sidebar-border: oklch(0.91 0.05 11.40);
- --sidebar-ring: oklch(0.92 0.04 12.39);
- --card-shadow: 0px 0px 3px 0px oklch(0.3 0.0891 19.6 / 0.08), 0px 2px 4px -1px oklch(0.3 0.0891 19.6 / 0.08);
- --glass-bg: rgba(253, 248, 247, 0.82);
- --surface: oklch(0.97 0.02 12.78);
- --surface-elevated: oklch(0.98 0.01 17.28);
- --surface-subtle: oklch(0.95 0.02 11.28);
- --border-subtle: oklch(0.91 0.05 11.40);
+ --sidebar-border: oklch(0.91 0.05 11.4);
+ --sidebar-ring: oklch(0.92 0.04 12.39);
+ --card-shadow:
+ 0px 0px 3px 0px oklch(0.3 0.0891 19.6 / 0.08),
+ 0px 2px 4px -1px oklch(0.3 0.0891 19.6 / 0.08);
+ --glass-bg: rgba(253, 248, 247, 0.82);
+ --surface: oklch(0.97 0.02 12.78);
+ --surface-elevated: oklch(0.98 0.01 17.28);
+ --surface-subtle: oklch(0.95 0.02 11.28);
+ --border-subtle: oklch(0.91 0.05 11.4);
}
.dark[data-theme-variant="valorant"] {
- --background: oklch(0.16 0.03 17.48);
- --foreground: oklch(0.99 0.00 359.99);
- --card: oklch(0.21 0.05 19.26);
- --card-foreground: oklch(0.98 0 0);
- --popover: oklch(0.26 0.07 19);
- --popover-foreground: oklch(0.99 0.00 359.99);
- --primary: oklch(0.67 0.22 21.34);
- --primary-foreground: oklch(0.99 0.00 359.99);
- --secondary: oklch(0.3 0.0891 19.6);
- --secondary-foreground: oklch(0.95 0.02 11.28);
- --muted: oklch(0.26 0.07 19);
- --muted-foreground: oklch(0.72 0.04 18);
- --accent: oklch(0.43 0.13 20.62);
- --accent-foreground: oklch(0.99 0.00 359.99);
- --destructive: oklch(0.80 0.17 73.27);
- --border: oklch(0.31 0.09 19.80);
- --input: oklch(0.39 0.12 20.37);
- --ring: oklch(0.50 0.16 20.89);
- --sidebar: oklch(0.26 0.07 19);
- --sidebar-foreground: oklch(0.99 0.00 359.99);
- --sidebar-primary: oklch(0.67 0.22 21.34);
- --sidebar-primary-foreground: oklch(0.99 0.00 359.99);
- --sidebar-accent: oklch(0.43 0.13 20.62);
- --sidebar-accent-foreground: oklch(0.99 0.00 359.99);
- --sidebar-border: oklch(0.39 0.12 20.37);
- --sidebar-ring: oklch(0.50 0.16 20.89);
- --card-shadow: 0 1px 3px 0px oklch(0.00 0 0 / 0.10), 0 2px 4px -1px oklch(0.00 0 0 / 0.10);
- --glass-bg: rgba(33, 14, 12, 0.82);
- --surface: oklch(0.21 0.05 19.26);
- --surface-elevated: oklch(0.26 0.07 19);
- --surface-subtle: oklch(0.3 0.0891 19.6);
- --border-subtle: oklch(0.31 0.09 19.80);
+ --background: oklch(0.16 0.03 17.48);
+ --foreground: oklch(0.99 0 359.99);
+ --card: oklch(0.21 0.05 19.26);
+ --card-foreground: oklch(0.98 0 0);
+ --popover: oklch(0.26 0.07 19);
+ --popover-foreground: oklch(0.99 0 359.99);
+ --primary: oklch(0.67 0.22 21.34);
+ --primary-foreground: oklch(0.99 0 359.99);
+ --secondary: oklch(0.3 0.0891 19.6);
+ --secondary-foreground: oklch(0.95 0.02 11.28);
+ --muted: oklch(0.26 0.07 19);
+ --muted-foreground: oklch(0.72 0.04 18);
+ --accent: oklch(0.43 0.13 20.62);
+ --accent-foreground: oklch(0.99 0 359.99);
+ --destructive: oklch(0.8 0.17 73.27);
+ --border: oklch(0.31 0.09 19.8);
+ --input: oklch(0.39 0.12 20.37);
+ --ring: oklch(0.5 0.16 20.89);
+ --sidebar: oklch(0.26 0.07 19);
+ --sidebar-foreground: oklch(0.99 0 359.99);
+ --sidebar-primary: oklch(0.67 0.22 21.34);
+ --sidebar-primary-foreground: oklch(0.99 0 359.99);
+ --sidebar-accent: oklch(0.43 0.13 20.62);
+ --sidebar-accent-foreground: oklch(0.99 0 359.99);
+ --sidebar-border: oklch(0.39 0.12 20.37);
+ --sidebar-ring: oklch(0.5 0.16 20.89);
+ --card-shadow:
+ 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 2px 4px -1px oklch(0 0 0 / 0.1);
+ --glass-bg: rgba(33, 14, 12, 0.82);
+ --surface: oklch(0.21 0.05 19.26);
+ --surface-elevated: oklch(0.26 0.07 19);
+ --surface-subtle: oklch(0.3 0.0891 19.6);
+ --border-subtle: oklch(0.31 0.09 19.8);
}
/* ── Full-palette theme: Ghibli Studio ──────────────────────────────── */
[data-theme-variant="ghibli"] {
- --background: oklch(0.91 0.05 82.69);
- --foreground: oklch(0.41 0.08 79.04);
- --card: oklch(0.92 0.04 83.86);
- --card-foreground: oklch(0.41 0.08 73.75);
- --popover: oklch(0.92 0.04 83.86);
- --popover-foreground: oklch(0.41 0.08 73.75);
- --primary: oklch(0.71 0.10 111.99);
- --primary-foreground: oklch(0.98 0.01 3.71);
- --secondary: oklch(0.88 0.05 83.41);
- --secondary-foreground: oklch(0.51 0.08 79.21);
- --muted: oklch(0.86 0.06 83.48);
- --muted-foreground: oklch(0.51 0.08 74.26);
- --accent: oklch(0.86 0.05 84.50);
- --accent-foreground: oklch(0.26 0.02 358.42);
- --destructive: oklch(0.63 0.24 29.21);
- --border: oklch(0.74 0.06 79.81);
- --input: oklch(0.74 0.06 79.81);
- --ring: oklch(0.51 0.08 74.26);
- --sidebar: oklch(0.87 0.06 83.96);
- --sidebar-foreground: oklch(0.41 0.08 79.04);
- --sidebar-primary: oklch(0.26 0.02 358.42);
+ --background: oklch(0.91 0.05 82.69);
+ --foreground: oklch(0.41 0.08 79.04);
+ --card: oklch(0.92 0.04 83.86);
+ --card-foreground: oklch(0.41 0.08 73.75);
+ --popover: oklch(0.92 0.04 83.86);
+ --popover-foreground: oklch(0.41 0.08 73.75);
+ --primary: oklch(0.71 0.1 111.99);
+ --primary-foreground: oklch(0.98 0.01 3.71);
+ --secondary: oklch(0.88 0.05 83.41);
+ --secondary-foreground: oklch(0.51 0.08 79.21);
+ --muted: oklch(0.86 0.06 83.48);
+ --muted-foreground: oklch(0.51 0.08 74.26);
+ --accent: oklch(0.86 0.05 84.5);
+ --accent-foreground: oklch(0.26 0.02 358.42);
+ --destructive: oklch(0.63 0.24 29.21);
+ --border: oklch(0.74 0.06 79.81);
+ --input: oklch(0.74 0.06 79.81);
+ --ring: oklch(0.51 0.08 74.26);
+ --sidebar: oklch(0.87 0.06 83.96);
+ --sidebar-foreground: oklch(0.41 0.08 79.04);
+ --sidebar-primary: oklch(0.26 0.02 358.42);
--sidebar-primary-foreground: oklch(0.98 0.01 3.71);
- --sidebar-accent: oklch(0.83 0.06 84.46);
+ --sidebar-accent: oklch(0.83 0.06 84.46);
--sidebar-accent-foreground: oklch(0.26 0.02 358.42);
- --sidebar-border: oklch(0.91 0.00 0.43);
- --sidebar-ring: oklch(0.71 0.00 0.37);
- --card-shadow: 0 1px 3px 0px oklch(0.00 0 0 / 0.08), 0 2px 4px -1px oklch(0.00 0 0 / 0.08);
- --glass-bg: rgba(244, 237, 216, 0.80);
- --surface: oklch(0.91 0.05 82.69);
- --surface-elevated: oklch(0.92 0.04 83.86);
- --surface-subtle: oklch(0.88 0.05 83.41);
- --border-subtle: oklch(0.74 0.06 79.81);
+ --sidebar-border: oklch(0.91 0 0.43);
+ --sidebar-ring: oklch(0.71 0 0.37);
+ --card-shadow:
+ 0 1px 3px 0px oklch(0 0 0 / 0.08), 0 2px 4px -1px oklch(0 0 0 / 0.08);
+ --glass-bg: rgba(244, 237, 216, 0.8);
+ --surface: oklch(0.91 0.05 82.69);
+ --surface-elevated: oklch(0.92 0.04 83.86);
+ --surface-subtle: oklch(0.88 0.05 83.41);
+ --border-subtle: oklch(0.74 0.06 79.81);
}
.dark[data-theme-variant="ghibli"] {
- --background: oklch(0.20 0.01 48.35);
- --foreground: oklch(0.88 0.05 79.26);
- --card: oklch(0.25 0.01 56.14);
- --card-foreground: oklch(0.88 0.05 79.26);
- --popover: oklch(0.25 0.01 56.14);
- --popover-foreground: oklch(0.88 0.05 79.26);
- --primary: oklch(0.64 0.05 115.39);
- --primary-foreground: oklch(0.98 0.01 3.71);
- --secondary: oklch(0.33 0.02 60.70);
- --secondary-foreground: oklch(0.88 0.05 83.41);
- --muted: oklch(0.27 0.01 39.35);
- --muted-foreground: oklch(0.74 0.06 79.81);
- --accent: oklch(0.33 0.02 60.70);
- --accent-foreground: oklch(0.86 0.05 84.50);
- --destructive: oklch(0.63 0.24 29.21);
- --border: oklch(0.33 0.02 60.70);
- --input: oklch(0.33 0.02 60.70);
- --ring: oklch(0.64 0.05 115.39);
- --sidebar: oklch(0.23 0.01 56.09);
- --sidebar-foreground: oklch(0.88 0.05 79.26);
- --sidebar-primary: oklch(0.64 0.05 115.39);
+ --background: oklch(0.2 0.01 48.35);
+ --foreground: oklch(0.88 0.05 79.26);
+ --card: oklch(0.25 0.01 56.14);
+ --card-foreground: oklch(0.88 0.05 79.26);
+ --popover: oklch(0.25 0.01 56.14);
+ --popover-foreground: oklch(0.88 0.05 79.26);
+ --primary: oklch(0.64 0.05 115.39);
+ --primary-foreground: oklch(0.98 0.01 3.71);
+ --secondary: oklch(0.33 0.02 60.7);
+ --secondary-foreground: oklch(0.88 0.05 83.41);
+ --muted: oklch(0.27 0.01 39.35);
+ --muted-foreground: oklch(0.74 0.06 79.81);
+ --accent: oklch(0.33 0.02 60.7);
+ --accent-foreground: oklch(0.86 0.05 84.5);
+ --destructive: oklch(0.63 0.24 29.21);
+ --border: oklch(0.33 0.02 60.7);
+ --input: oklch(0.33 0.02 60.7);
+ --ring: oklch(0.64 0.05 115.39);
+ --sidebar: oklch(0.23 0.01 56.09);
+ --sidebar-foreground: oklch(0.88 0.05 79.26);
+ --sidebar-primary: oklch(0.64 0.05 115.39);
--sidebar-primary-foreground: oklch(0.98 0.01 3.71);
- --sidebar-accent: oklch(0.33 0.02 60.70);
- --sidebar-accent-foreground: oklch(0.86 0.05 84.50);
- --sidebar-border: oklch(0.33 0.02 60.70);
- --sidebar-ring: oklch(0.64 0.05 115.39);
- --card-shadow: 0 1px 3px 0px oklch(0.00 0 0 / 0.20), 0 2px 4px -1px oklch(0.00 0 0 / 0.20);
- --glass-bg: rgba(33, 25, 14, 0.82);
- --surface: oklch(0.25 0.01 56.14);
- --surface-elevated: oklch(0.27 0.01 39.35);
- --surface-subtle: oklch(0.33 0.02 60.70);
- --border-subtle: oklch(0.33 0.02 60.70);
+ --sidebar-accent: oklch(0.33 0.02 60.7);
+ --sidebar-accent-foreground: oklch(0.86 0.05 84.5);
+ --sidebar-border: oklch(0.33 0.02 60.7);
+ --sidebar-ring: oklch(0.64 0.05 115.39);
+ --card-shadow:
+ 0 1px 3px 0px oklch(0 0 0 / 0.2), 0 2px 4px -1px oklch(0 0 0 / 0.2);
+ --glass-bg: rgba(33, 25, 14, 0.82);
+ --surface: oklch(0.25 0.01 56.14);
+ --surface-elevated: oklch(0.27 0.01 39.35);
+ --surface-subtle: oklch(0.33 0.02 60.7);
+ --border-subtle: oklch(0.33 0.02 60.7);
}
-
@layer base {
-
* {
@apply border-border outline-ring/50;
}
@@ -436,15 +462,26 @@ body,
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
text-rendering: optimizeLegibility;
background:
- radial-gradient(circle at 18% -10%, color-mix(in oklch, var(--primary) 14%, transparent) 0, transparent 32rem),
- linear-gradient(180deg, color-mix(in oklch, var(--background) 96%, white), var(--background));
+ radial-gradient(
+ circle at 18% -10%,
+ color-mix(in oklch, var(--primary) 14%, transparent) 0,
+ transparent 32rem
+ ),
+ linear-gradient(
+ 180deg,
+ color-mix(in oklch, var(--background) 96%, white),
+ var(--background)
+ );
}
-
+
/* Apply monospace font to technical elements */
- code, pre, kbd, samp {
+ code,
+ pre,
+ kbd,
+ samp {
font-family: var(--font-mono);
}
-
+
/* Ensure font-mono class uses our mono font */
.font-mono {
font-family: var(--font-mono) !important;
@@ -461,16 +498,7 @@ body {
user-select: none;
}
-input,
-textarea,
-[contenteditable="true"],
-pre,
-code,
-.selectable,
-.font-mono {
- -webkit-user-select: text;
- user-select: text;
-}
+
@layer utilities {
/* Webkit browsers (Chrome, Safari, Edge) - Applied globally */
@@ -514,7 +542,11 @@ code,
.app-surface {
background:
- linear-gradient(180deg, color-mix(in oklch, var(--surface) 92%, transparent), var(--background)),
+ linear-gradient(
+ 180deg,
+ color-mix(in oklch, var(--surface) 92%, transparent),
+ var(--background)
+ ),
var(--background);
}
@@ -545,8 +577,15 @@ code,
.hairline-grid {
background-image:
- linear-gradient(color-mix(in oklch, var(--border) 38%, transparent) 1px, transparent 1px),
- linear-gradient(90deg, color-mix(in oklch, var(--border) 38%, transparent) 1px, transparent 1px);
+ linear-gradient(
+ color-mix(in oklch, var(--border) 38%, transparent) 1px,
+ transparent 1px
+ ),
+ linear-gradient(
+ 90deg,
+ color-mix(in oklch, var(--border) 38%, transparent) 1px,
+ transparent 1px
+ );
background-size: 28px 28px;
}
@@ -560,13 +599,17 @@ code,
.route-transition--in {
opacity: 1;
transform: translateY(0);
- transition: opacity 260ms cubic-bezier(0.22, 1, 0.36, 1), transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
+ transition:
+ opacity 260ms cubic-bezier(0.22, 1, 0.36, 1),
+ transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
}
.route-transition--out {
opacity: 0.72;
transform: translateY(2px);
- transition: opacity 130ms cubic-bezier(0.4, 0, 1, 1), transform 130ms cubic-bezier(0.4, 0, 1, 1);
+ transition:
+ opacity 130ms cubic-bezier(0.4, 0, 1, 1),
+ transform 130ms cubic-bezier(0.4, 0, 1, 1);
}
@media (prefers-reduced-motion: reduce) {
@@ -586,4 +629,35 @@ code,
.dark * {
scrollbar-color: rgba(156, 163, 175, 0.2) transparent;
}
+
+ .theme-transitioning *,
+ .theme-transitioning *::before,
+ .theme-transitioning *::after {
+ transition-duration: 0ms !important;
+ transition-delay: 0ms !important;
+ }
+
+ .theme-transitioning [data-slot="dialog-content"],
+ .theme-transitioning [data-slot="dialog-content"] *,
+ .theme-transitioning [data-slot="dialog-content"] *::before,
+ .theme-transitioning [data-slot="dialog-content"] *::after {
+ transition-property:
+ color, background-color, border-color, box-shadow, opacity !important;
+ transition-duration: 260ms !important;
+ transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1) !important;
+ }
+
+ /* View Transition API rules for buttery smooth theme toggling */
+ ::view-transition-old(root),
+ ::view-transition-new(root) {
+ animation-duration: 300ms;
+ animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ ::view-transition-old(root),
+ ::view-transition-new(root) {
+ animation-duration: 0ms;
+ }
+ }
}
diff --git a/src/components/layout/BridgeStatus.tsx b/src/components/layout/BridgeStatus.tsx
new file mode 100644
index 0000000..c30bcae
--- /dev/null
+++ b/src/components/layout/BridgeStatus.tsx
@@ -0,0 +1,55 @@
+import { useEffect, useState } from 'react';
+import { onConnectionStateChange, isBridgeHealthy, restartBridge } from '@/services/bridge/bridgeClient';
+import { WifiOff, RefreshCcw } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+
+const BridgeStatus = () => {
+ const [healthy, setHealthy] = useState(isBridgeHealthy());
+ const [restarting, setRestarting] = useState(false);
+
+ useEffect(() => {
+ // Sync initial state
+ setHealthy(isBridgeHealthy());
+
+ // Subscribe to changes
+ return onConnectionStateChange((h) => setHealthy(h));
+ }, []);
+
+ if (healthy) return null;
+
+ const handleRestart = async () => {
+ if (restarting) return;
+ setRestarting(true);
+ try {
+ await restartBridge();
+ } finally {
+ setRestarting(false);
+ }
+ };
+
+ return (
+
+
+
+ Bridge Disconnected
+
+
+
+
+
+ );
+};
+
+export default BridgeStatus;
diff --git a/src/components/layout/CommandPalette.tsx b/src/components/layout/CommandPalette.tsx
index bf86693..99f97ab 100644
--- a/src/components/layout/CommandPalette.tsx
+++ b/src/components/layout/CommandPalette.tsx
@@ -189,7 +189,7 @@ export function CommandPalette() {
const dbId = React.useMemo(() => {
const parts = location.pathname.split("/");
- if (parts.length === 2 && parts[1] && !["projects", "settings"].includes(parts[1])) {
+ if (parts.length === 2 && parts[1] && !["settings"].includes(parts[1])) {
return parts[1];
}
return null;
@@ -248,7 +248,6 @@ export function CommandPalette() {
const pages = React.useMemo(() => [
{ icon: Home, label: "Dashboard", path: "/" },
- { icon: FolderOpen, label: "Projects", path: "/projects" },
{ icon: Settings, label: "Settings", path: "/settings" },
], []);
@@ -407,7 +406,7 @@ export function CommandPalette() {
)}
- {/* Projects */}
+ {/* Projects — navigate to linked database */}
{projects.length > 0 && (
}>
{projects.map((project) => (
@@ -418,8 +417,8 @@ export function CommandPalette() {
type="project"
query={search}
onSelect={() =>
- runCommand(() => navigate("/projects"), {
- id: project.id, type: "project", label: project.name, path: "/projects",
+ runCommand(() => navigate(`/${project.databaseId}`), {
+ id: project.id, type: "project", label: project.name, path: `/${project.databaseId}`,
})
}
/>
diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx
index 91289e0..3fe1d2d 100644
--- a/src/components/layout/TitleBar.tsx
+++ b/src/components/layout/TitleBar.tsx
@@ -1,7 +1,9 @@
-import { Minus, Square, X } from 'lucide-react';
+import { Maximize2, Minus, Square, X } from 'lucide-react';
import { getCurrentWindow } from '@tauri-apps/api/window';
+import BridgeStatus from './BridgeStatus';
const TitleBar = () => {
+
const handleMinimize = async () => {
try {
const appWindow = getCurrentWindow();
@@ -32,7 +34,7 @@ const TitleBar = () => {
return (
{/* App Title - Left */}
@@ -40,29 +42,41 @@ const TitleBar = () => {
RelWave
+ {/* Bridge Status - Center */}
+
+
+
+
{/* Window Controls - Right */}
-
+
+
+ {/* Close - Red */}
+
+
+
+
+ {/* Minimize - Yellow */}
-
+
+
+ {/* Maximize - Green */}
-
-
-
-
+
+
);
diff --git a/src/components/layout/VerticalIconBar.tsx b/src/components/layout/VerticalIconBar.tsx
index 10aca4c..47d7a61 100644
--- a/src/components/layout/VerticalIconBar.tsx
+++ b/src/components/layout/VerticalIconBar.tsx
@@ -1,4 +1,4 @@
-import { Activity, Home, Database, Search, GitBranch, GitCommitHorizontal, Settings, Layers, Terminal, FolderOpen } from 'lucide-react';
+import { Activity, Home, Database, Search, GitBranch, GitCommitHorizontal, Settings, Layers, Terminal, History } from 'lucide-react';
import { Link, useLocation } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import {
@@ -7,7 +7,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
-export type PanelType = 'data' | 'sql-workspace' | 'query-builder' | 'schema-explorer' | 'er-diagram' | 'monitoring' | 'git-status';
+export type PanelType = 'data' | 'sql-workspace' | 'query-builder' | 'schema-explorer' | 'er-diagram' | 'monitoring' | 'git-status' | 'migrations';
interface VerticalIconBarProps {
dbId?: string;
@@ -18,8 +18,6 @@ interface VerticalIconBarProps {
const globalNavigationItems = [
{ icon: Home, label: 'Dashboard', path: '/' },
- { icon: FolderOpen, label: 'Projects', path: '/projects' },
- { icon: Settings, label: 'Settings', path: '/settings' },
];
function supportsMonitoring(databaseType?: string) {
@@ -38,13 +36,14 @@ export default function VerticalIconBar({ dbId, databaseType, activePanel, onPan
};
// Database-specific panel items (only shown when dbId is provided)
- const databasePanelItems: Array<{ icon: typeof Terminal; label: string; panel: PanelType }> = dbId ? [
+ const databasePanelItems: Array<{ icon: any; label: string; panel: PanelType }> = dbId ? [
{ icon: Layers, label: 'Data View', panel: 'data' },
{ icon: Terminal, label: 'SQL Workspace', panel: 'sql-workspace' },
{ icon: Search, label: 'Query Builder', panel: 'query-builder' },
{ icon: GitBranch, label: 'Schema Explorer', panel: 'schema-explorer' },
{ icon: Database, label: 'ER Diagram', panel: 'er-diagram' },
...(supportsMonitoring(databaseType) ? [{ icon: Activity, label: 'Monitoring', panel: 'monitoring' as PanelType }] : []),
+ { icon: History, label: 'Migrations', panel: 'migrations' },
{ icon: GitCommitHorizontal, label: 'Git Status', panel: 'git-status' },
] : [];
diff --git a/src/components/providers/ThemeProvider.tsx b/src/components/providers/ThemeProvider.tsx
index 473f79e..3a3686b 100644
--- a/src/components/providers/ThemeProvider.tsx
+++ b/src/components/providers/ThemeProvider.tsx
@@ -1,6 +1,7 @@
import { createContext, useContext, useEffect, useState } from "react"
+import { flushSync } from "react-dom"
-type Theme = "dark" | "light" | "system"
+export type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
@@ -20,44 +21,78 @@ const initialState: ThemeProviderState = {
const ThemeProviderContext = createContext
(initialState)
+function getSystemTheme(): Exclude {
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light"
+}
+
+function applyTheme(theme: Theme) {
+ const root = window.document.documentElement
+ const resolvedTheme = theme === "system" ? getSystemTheme() : theme
+ root.classList.remove("light", "dark")
+ root.classList.add(resolvedTheme)
+}
+
export function ThemeProvider({
children,
defaultTheme = "system",
- storageKey = "vite-ui-theme",
- ...props
+ storageKey = "relwave:theme",
}: ThemeProviderProps) {
- const [theme, setTheme] = useState(
- () => (localStorage.getItem(storageKey) as Theme) || defaultTheme
- )
+ const [theme, setTheme] = useState(() => {
+ const stored = (localStorage.getItem(storageKey) as Theme) || defaultTheme
+ applyTheme(stored)
+ return stored
+ })
useEffect(() => {
- const root = window.document.documentElement
-
- root.classList.remove("light", "dark")
+ applyTheme(theme)
+ }, [theme])
- if (theme === "system") {
- const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
- .matches
- ? "dark"
- : "light"
+ useEffect(() => {
+ if (theme !== "system") return
- root.classList.add(systemTheme)
- return
- }
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
+ const handleSystemThemeChange = () => applyTheme("system")
- root.classList.add(theme)
+ mediaQuery.addEventListener("change", handleSystemThemeChange)
+ return () => mediaQuery.removeEventListener("change", handleSystemThemeChange)
}, [theme])
const value = {
theme,
- setTheme: (theme: Theme) => {
- localStorage.setItem(storageKey, theme)
- setTheme(theme)
+ setTheme: (newTheme: Theme) => {
+ const root = window.document.documentElement
+
+ if (!document.startViewTransition) {
+ localStorage.setItem(storageKey, newTheme)
+ applyTheme(newTheme)
+ setTheme(newTheme)
+ return
+ }
+
+ root.classList.add("theme-transitioning")
+
+ const transition = document.startViewTransition(() => {
+ localStorage.setItem(storageKey, newTheme)
+ flushSync(() => {
+ applyTheme(newTheme)
+ setTheme(newTheme)
+ })
+ })
+
+ transition.ready.catch(() => {
+ root.classList.remove("theme-transitioning")
+ })
+
+ transition.finished.finally(() => {
+ root.classList.remove("theme-transitioning")
+ })
},
}
return (
-
+
{children}
)
diff --git a/src/components/shared/WhatsNewDialog.tsx b/src/components/shared/WhatsNewDialog.tsx
index 69654cd..c7ee451 100644
--- a/src/components/shared/WhatsNewDialog.tsx
+++ b/src/components/shared/WhatsNewDialog.tsx
@@ -1,155 +1,62 @@
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useState } from "react";
import { getVersion } from "@tauri-apps/api/app";
-import { Sparkles } from "lucide-react";
+import { Sparkles, ArrowRight } from "lucide-react";
+import ReactMarkdown from "react-markdown";
import {
Dialog,
DialogContent,
- DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
-type StoredInstalledUpdate = {
- version: string;
- body?: string;
- date?: string;
- previousVersion?: string;
- installedAt?: string;
-};
-
-type NoteSection = {
- title: string;
- items: string[];
-};
-
-/**
- * Written by useUpdater after a successful in-app download.
- * Contains the release body, date, previousVersion, etc.
- */
-const LAST_INSTALLED_UPDATE_KEY = "relwave:last-installed-update";
+const GITHUB_REPO = "Relwave/relwave-app";
-/**
- * Written on every launch with the current app version.
- * Used to detect version changes across manual installs, where the
- * in-app updater never runs and LAST_INSTALLED_UPDATE_KEY is never set.
- */
const LAST_SEEN_VERSION_KEY = "relwave:last-seen-version";
-const TITLE_MAP: Record = {
- new: "New",
- added: "New",
- improvements: "Improved",
- improved: "Improved",
- changes: "Improved",
- changed: "Improved",
- fixes: "Fixed",
- fixed: "Fixed",
- bugfixes: "Fixed",
- bugs: "Fixed",
- security: "Security",
+type ReleaseInfo = {
+ version: string;
+ body?: string;
+ previousVersion?: string;
};
-const FALLBACK_SECTIONS: NoteSection[] = [
- {
- title: "Highlights",
- items: [
- "General improvements and bug fixes.",
- "Better stability and performance.",
- "Quality updates to the update and database workflows.",
- ],
- },
-];
-
-function normalizeSectionTitle(title: string): string {
- const key = title.trim().toLowerCase().replace(/[^a-z]/g, "");
- return TITLE_MAP[key] ?? title.trim();
-}
-
-function parseReleaseBody(body?: string): NoteSection[] {
- if (!body || !body.trim()) {
- return FALLBACK_SECTIONS;
- }
-
- // GitHub generates a default `notes` value when no release notes are written.
- // Treat it (and similar content-free strings) as "no notes" so we fall back
- // to the generic highlights instead of surfacing a useless placeholder.
- const KNOWN_PLACEHOLDERS = [
- "see the assets to download this version and install",
- "no release notes provided",
- ];
- const normalized = body.trim().toLowerCase();
- if (KNOWN_PLACEHOLDERS.some((p) => normalized === p || normalized.startsWith(p))) {
- return FALLBACK_SECTIONS;
- }
-
- const lines = body
- .split(/\r?\n/)
- .map((line) => line.trim())
- .filter(Boolean);
-
- if (!lines.length) {
- return FALLBACK_SECTIONS;
- }
-
- const sections: NoteSection[] = [];
- let current: NoteSection = { title: "Highlights", items: [] };
-
- const pushCurrentIfNeeded = () => {
- if (current.items.length > 0) {
- sections.push(current);
- }
- };
-
- for (const line of lines) {
- const headingMatch = line.match(/^#{1,6}\s+(.+)$/);
- if (headingMatch) {
- pushCurrentIfNeeded();
- current = { title: normalizeSectionTitle(headingMatch[1]), items: [] };
- continue;
- }
-
- const bulletMatch = line.match(/^[-*]\s+(.+)$/);
- if (bulletMatch) {
- current.items.push(bulletMatch[1].trim());
- continue;
- }
-
- const numberedMatch = line.match(/^\d+[.)]\s+(.+)$/);
- if (numberedMatch) {
- current.items.push(numberedMatch[1].trim());
- continue;
- }
-
- // Treat plain text lines as standalone highlights.
- current.items.push(line);
- }
-
- pushCurrentIfNeeded();
-
- if (!sections.length) {
- return FALLBACK_SECTIONS;
- }
-
- // Merge duplicate section titles while preserving order.
- const merged: NoteSection[] = [];
- for (const section of sections) {
- const existing = merged.find((entry) => entry.title === section.title);
- if (existing) {
- existing.items.push(...section.items);
- continue;
- }
- merged.push({ title: section.title, items: [...section.items] });
+const FALLBACK_BODY = `
+## Highlights
+- General improvements and bug fixes.
+- Better stability and performance.
+- Quality updates to the update and database workflows.
+`.trim();
+
+async function fetchReleaseNotes(version: string): Promise {
+ const cacheKey = `relwave:release-notes-${version}`;
+ const cached = localStorage.getItem(cacheKey);
+ if (cached) return cached;
+
+ try {
+ const res = await fetch(
+ `https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${version}`,
+ {
+ headers: {
+ Accept: "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ }
+ );
+ if (!res.ok) return undefined;
+ const data = await res.json();
+ const body: string | undefined = data.body ?? undefined;
+ if (body) localStorage.setItem(cacheKey, body);
+ return body;
+ } catch {
+ return undefined;
}
-
- return merged;
}
export function WhatsNewDialog() {
const [open, setOpen] = useState(false);
- const [currentVersion, setCurrentVersion] = useState("");
- const [releaseInfo, setReleaseInfo] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [releaseInfo, setReleaseInfo] = useState(null);
useEffect(() => {
let mounted = true;
@@ -159,106 +66,185 @@ export function WhatsNewDialog() {
const installedVersion = await getVersion();
if (!mounted) return;
- setCurrentVersion(installedVersion);
-
const lastSeenVersion = localStorage.getItem(LAST_SEEN_VERSION_KEY);
-
- // Show the dialog whenever the stored version differs from the current
- // version. This covers both in-app updater installs AND manual installs
- // where useUpdater never runs (so LAST_INSTALLED_UPDATE_KEY is absent).
- const isNewVersion =
- !lastSeenVersion || lastSeenVersion !== installedVersion;
-
+ const isNewVersion = !lastSeenVersion || lastSeenVersion !== installedVersion;
if (!isNewVersion) return;
- // Try to read the richer update payload written by useUpdater (contains
- // the release body, date, previous version, etc.). If it's absent or
- // for a different version we still show the dialog — just with fallback
- // content rather than nothing at all.
- let parsed: StoredInstalledUpdate | null = null;
- const raw = localStorage.getItem(LAST_INSTALLED_UPDATE_KEY);
- if (raw) {
- try {
- const candidate = JSON.parse(raw) as StoredInstalledUpdate;
- // Only use the payload if it matches the installed version.
- if (candidate.version === installedVersion) {
- parsed = candidate;
- }
- } catch {
- localStorage.removeItem(LAST_INSTALLED_UPDATE_KEY);
- }
- }
-
- // Fall back to a minimal record so the dialog still shows useful info.
- if (!parsed) {
- parsed = {
- version: installedVersion,
- previousVersion: lastSeenVersion ?? undefined,
- };
- }
+ setLoading(true);
+ const body = await fetchReleaseNotes(installedVersion);
+ if (!mounted) return;
- setReleaseInfo(parsed);
+ setReleaseInfo({
+ version: installedVersion,
+ previousVersion: lastSeenVersion ?? undefined,
+ body: body ?? FALLBACK_BODY,
+ });
+ setLoading(false);
setOpen(true);
} catch {
- // Non-fatal: if version cannot be read, skip popup.
+ setLoading(false);
}
};
void load();
+ return () => { mounted = false; };
+ }, []);
- return () => {
- mounted = false;
+ // Dev-only: Cmd+Shift+W resets and reloads
+ useEffect(() => {
+ if (!import.meta.env.DEV) return;
+ const handler = (e: KeyboardEvent) => {
+ if (e.metaKey && e.shiftKey && e.key === "W") {
+ localStorage.removeItem(LAST_SEEN_VERSION_KEY);
+ Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i))
+ .filter((k) => k?.startsWith("relwave:release-notes-"))
+ .forEach((k) => k && localStorage.removeItem(k));
+ window.location.reload();
+ }
};
+ window.addEventListener("keydown", handler);
+ return () => window.removeEventListener("keydown", handler);
}, []);
- const sections = useMemo(() => parseReleaseBody(releaseInfo?.body), [releaseInfo?.body]);
-
const handleClose = (nextOpen: boolean) => {
- if (!nextOpen && currentVersion) {
- // Record that the user has seen the dialog for this version.
- // Both keys are updated so future launches don't re-show the dialog.
- localStorage.setItem(LAST_SEEN_VERSION_KEY, currentVersion);
- localStorage.removeItem(LAST_INSTALLED_UPDATE_KEY);
+ if (!nextOpen && releaseInfo?.version) {
+ localStorage.setItem(LAST_SEEN_VERSION_KEY, releaseInfo.version);
}
setOpen(nextOpen);
};
- if (!releaseInfo) {
- return null;
- }
+ if (!releaseInfo && !loading) return null;
return (
-
-
-
-
- What's New in v{releaseInfo.version}
-
-
- {releaseInfo.previousVersion
- ? `Updated from v${releaseInfo.previousVersion} to v${releaseInfo.version}`
- : "Thanks for updating RelWave."}
-
+
+ {/* Header */}
+
+
+
+
+
+
+
+ What's new in RelWave
+
+
+ {releaseInfo?.previousVersion && (
+ <>
+
+ v{releaseInfo.previousVersion}
+
+
+ >
+ )}
+
+ v{releaseInfo?.version}
+
+
+
+
-
- {sections.map((section, index) => (
-
- {section.title}
-
- {section.items.map((item, itemIndex) => (
- {item}
- ))}
-
-
- ))}
+ {/* Body */}
+
+ {loading ? (
+
+ {[80, 60, 90, 50, 70].map((w, i) => (
+
+ ))}
+
+ ) : (
+
(
+
+ {children}
+
+ ),
+ h2: ({ children }) => (
+
+ {children}
+
+ ),
+ h3: ({ children }) => (
+
+ {children}
+
+ ),
+ ul: ({ children }) => (
+
+ ),
+ ol: ({ children }) => (
+ {children}
+ ),
+ li: ({ children }) => (
+
+
+ {children}
+
+ ),
+ p: ({ children }) => (
+
+ {children}
+
+ ),
+ strong: ({ children }) => (
+ {children}
+ ),
+ em: ({ children }) => (
+ {children}
+ ),
+ hr: () => (
+
+ ),
+ code: ({ children, className }) => {
+ const isBlock = !!className;
+ return isBlock ? (
+
+ {children}
+
+ ) : (
+
+ {children}
+
+ );
+ },
+ a: ({ href, children }) => (
+
+ {children}
+
+ ),
+ blockquote: ({ children }) => (
+
+ {children}
+
+ ),
+ }}
+ >
+ {releaseInfo?.body ?? ""}
+
+ )}
-
- handleClose(false)}>Got it
+ {/* Footer */}
+
+
+ Thanks for keeping RelWave up to date.
+
+ handleClose(false)} disabled={loading}>
+ Got it
+
-
-
+
+
);
-}
+}
\ No newline at end of file
diff --git a/src/components/ui/SQLPreviewSheet.tsx b/src/components/ui/SQLPreviewSheet.tsx
new file mode 100644
index 0000000..91eb835
--- /dev/null
+++ b/src/components/ui/SQLPreviewSheet.tsx
@@ -0,0 +1,72 @@
+import { Check, Copy, X } from "lucide-react";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { cn } from "@/lib/utils";
+
+interface SQLPreviewSheetProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ sql: string;
+ title?: string;
+}
+
+export default function SQLPreviewSheet({
+ open,
+ onOpenChange,
+ sql,
+ title = "SQL Preview",
+}: SQLPreviewSheetProps) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(sql);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+
+
+
+
+
+
+ {title}
+
+
+ Preview the SQL that will be executed.
+
+
+
+
+ {copied ? : }
+ {copied ? "Copied" : "Copy"}
+
+
+
+
+ Close
+
+
+
+
+
+
+
+ {sql || "No SQL available."}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx
new file mode 100644
index 0000000..20416f0
--- /dev/null
+++ b/src/components/ui/radio-group.tsx
@@ -0,0 +1,43 @@
+import * as React from "react"
+import { CircleIcon } from "lucide-react"
+import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function RadioGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function RadioGroupItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { RadioGroup, RadioGroupItem }
diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx
new file mode 100644
index 0000000..dcfa2b4
--- /dev/null
+++ b/src/components/ui/sheet.tsx
@@ -0,0 +1,141 @@
+import * as React from "react"
+import { XIcon } from "lucide-react"
+import { Dialog as SheetPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Sheet({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SheetTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SheetClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SheetPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SheetOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ side?: "top" | "right" | "bottom" | "left"
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..3ec6be7
--- /dev/null
+++ b/src/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/src/features/ai/components/AIHistoryDetailDialog.tsx b/src/features/ai/components/AIHistoryDetailDialog.tsx
new file mode 100644
index 0000000..f59841d
--- /dev/null
+++ b/src/features/ai/components/AIHistoryDetailDialog.tsx
@@ -0,0 +1,274 @@
+import { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import {
+ Bot,
+ Copy,
+ Check,
+ Trash2,
+ ChevronDown,
+ Clock,
+ Cpu,
+ Hash,
+ FileText,
+} from "lucide-react";
+import { MarkdownRenderer } from "./AIResultDialog";
+import type { AIHistoryEntry } from "@/services/bridge/ai";
+import { cn } from "@/lib/utils";
+
+interface AIHistoryDetailDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ entry: AIHistoryEntry | null;
+ onDelete?: (id: number) => void;
+}
+
+const FEATURE_LABELS: Record = {
+ "schema-analysis": "Schema Analysis",
+ "query-explanation": "Query Explanation",
+ "chart-recommendation": "Chart Recommendation",
+};
+
+const PROVIDER_LABELS: Record = {
+ anthropic: "Anthropic",
+ openai: "OpenAI",
+ gemini: "Gemini",
+ groq: "Groq",
+ mistral: "Mistral",
+ ollama: "Ollama",
+};
+
+function formatDate(iso: string): string {
+ try {
+ return new Date(iso).toLocaleString(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ });
+ } catch {
+ return iso;
+ }
+}
+
+export function AIHistoryDetailDialog({
+ open,
+ onOpenChange,
+ entry,
+ onDelete,
+}: AIHistoryDetailDialogProps) {
+ const [copiedField, setCopiedField] = useState<"prompt" | "response" | null>(null);
+ const [promptOpen, setPromptOpen] = useState(false);
+
+ if (!entry) return null;
+
+ const handleCopy = async (text: string, field: "prompt" | "response") => {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopiedField(field);
+ setTimeout(() => setCopiedField(null), 2000);
+ } catch (err) {
+ console.error("Failed to copy:", err);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ Analysis Details
+
+ {FEATURE_LABELS[entry.feature] ?? entry.feature}
+
+
+
+
+
+ {/* Metadata grid */}
+
+
}
+ label="Provider"
+ value={PROVIDER_LABELS[entry.provider] ?? entry.provider}
+ />
+
}
+ label="Model"
+ value={entry.model}
+ />
+
+
+
+
+ }
+ label="Created"
+ value={formatDate(entry.created_at)}
+ />
+
+
+
+ {entry.created_at}
+
+
+
+
}
+ label="Tokens"
+ value={entry.tokens_used != null ? entry.tokens_used.toLocaleString() : "N/A"}
+ />
+
+
+ {/* Prompt (collapsible) */}
+
+
+
+
+
+ Prompt
+
+
+ handleCopy(entry.prompt, "prompt")}
+ >
+ {copiedField === "prompt" ? (
+
+ ) : (
+
+ )}
+ {copiedField === "prompt" ? "Copied" : "Copy Prompt"}
+
+
+
+
+ {entry.prompt}
+
+
+
+
+ {/* Response */}
+
+
+ Response
+ handleCopy(entry.response, "response")}
+ >
+ {copiedField === "response" ? (
+
+ ) : (
+
+ )}
+ {copiedField === "response" ? "Copied" : "Copy Response"}
+
+
+
+
+
+
+
+ {/* Delete action */}
+ {onDelete && (
+
+
+
+
+
+ Delete Analysis
+
+
+
+
+ Delete this analysis?
+
+ This will permanently remove this AI analysis from your local history.
+ This action cannot be undone.
+
+
+
+ Cancel
+ {
+ onDelete(entry.id);
+ onOpenChange(false);
+ }}
+ >
+ Delete
+
+
+
+
+
+ )}
+
+
+
+ );
+}
+
+function MetaItem({
+ icon,
+ label,
+ value,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ value: string;
+}) {
+ return (
+
+ );
+}
diff --git a/src/features/ai/components/AIHistoryPanel.tsx b/src/features/ai/components/AIHistoryPanel.tsx
new file mode 100644
index 0000000..fdf573a
--- /dev/null
+++ b/src/features/ai/components/AIHistoryPanel.tsx
@@ -0,0 +1,359 @@
+import { useState, useEffect, useCallback } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { History, Trash2, ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
+import { aiService, type AIHistoryListItem, type AIHistoryEntry } from "@/services/bridge/ai";
+import { AIHistoryDetailDialog } from "./AIHistoryDetailDialog";
+import { cn } from "@/lib/utils";
+
+const PAGE_SIZE = 10;
+
+const FEATURE_OPTIONS = [
+ { value: "all", label: "All Features" },
+ { value: "schema-analysis", label: "Schema Analysis" },
+ { value: "query-explanation", label: "Query Explanation" },
+ { value: "chart-recommendation", label: "Chart Recommendation" },
+];
+
+const PROVIDER_OPTIONS = [
+ { value: "all", label: "All Providers" },
+ { value: "anthropic", label: "Anthropic" },
+ { value: "openai", label: "OpenAI" },
+ { value: "gemini", label: "Gemini" },
+ { value: "groq", label: "Groq" },
+ { value: "mistral", label: "Mistral" },
+ { value: "ollama", label: "Ollama" },
+];
+
+const FEATURE_LABELS: Record = {
+ "schema-analysis": "Schema Analysis",
+ "query-explanation": "Query Explanation",
+ "chart-recommendation": "Chart Recommendation",
+};
+
+const FEATURE_COLORS: Record = {
+ "schema-analysis": "border-violet-500/30 text-violet-600 bg-violet-500/8",
+ "query-explanation": "border-blue-500/30 text-blue-600 bg-blue-500/8",
+ "chart-recommendation": "border-amber-500/30 text-amber-600 bg-amber-500/8",
+};
+
+const PROVIDER_LABELS: Record = {
+ anthropic: "Anthropic",
+ openai: "OpenAI",
+ gemini: "Gemini",
+ groq: "Groq",
+ mistral: "Mistral",
+ ollama: "Ollama",
+};
+
+function timeAgo(isoDate: string): string {
+ const diff = Date.now() - new Date(isoDate).getTime();
+ const seconds = Math.floor(diff / 1000);
+ if (seconds < 60) return "just now";
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ if (days < 30) return `${days}d ago`;
+ const months = Math.floor(days / 30);
+ return `${months}mo ago`;
+}
+
+export default function AIHistoryPanel() {
+ const [items, setItems] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [featureFilter, setFeatureFilter] = useState("all");
+ const [providerFilter, setProviderFilter] = useState("all");
+
+ // Detail dialog
+ const [detailOpen, setDetailOpen] = useState(false);
+ const [detailEntry, setDetailEntry] = useState(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
+
+ const fetchHistory = useCallback(async () => {
+ setLoading(true);
+ try {
+ const result = await aiService.getHistory({
+ feature: featureFilter !== "all" ? featureFilter : undefined,
+ provider: providerFilter !== "all" ? providerFilter : undefined,
+ limit: PAGE_SIZE,
+ offset: page * PAGE_SIZE,
+ });
+ setItems(result.items);
+ setTotal(result.total);
+ } catch (err) {
+ console.error("Failed to load AI history:", err);
+ } finally {
+ setLoading(false);
+ }
+ }, [featureFilter, providerFilter, page]);
+
+ useEffect(() => {
+ fetchHistory();
+ }, [fetchHistory]);
+
+ // Reset page when filters change
+ useEffect(() => {
+ setPage(0);
+ }, [featureFilter, providerFilter]);
+
+ const handleRowClick = async (item: AIHistoryListItem) => {
+ setDetailLoading(true);
+ setDetailOpen(true);
+ try {
+ const entry = await aiService.getHistoryById(item.id);
+ setDetailEntry(entry);
+ } catch (err) {
+ console.error("Failed to load history detail:", err);
+ } finally {
+ setDetailLoading(false);
+ }
+ };
+
+ const handleDelete = async (id: number) => {
+ try {
+ await aiService.deleteHistory(id);
+ fetchHistory();
+ } catch (err) {
+ console.error("Failed to delete history entry:", err);
+ }
+ };
+
+ const handleClearAll = async () => {
+ try {
+ await aiService.clearHistory();
+ setItems([]);
+ setTotal(0);
+ setPage(0);
+ } catch (err) {
+ console.error("Failed to clear history:", err);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+ AI History
+ {total > 0 && (
+
+ {total}
+
+ )}
+
+
+ {total > 0 && (
+
+
+
+
+ Clear All
+
+
+
+
+ Clear all AI history?
+
+ This will permanently delete all {total} AI analysis entries from your local history.
+ Cached results will no longer be available. This action cannot be undone.
+
+
+
+ Cancel
+
+ Clear All History
+
+
+
+
+ )}
+
+
+ {/* Filters */}
+
+
+
+
+
+
+ {FEATURE_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+
+
+
+
+ {PROVIDER_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+
+
+
+ {loading ? (
+
+
+
+ ) : items.length === 0 ? (
+
+
+
No AI analysis history yet.
+
+ Results will appear here after you use AI features.
+
+
+ ) : (
+ <>
+
+
+
+
+ Feature
+ Database
+ Provider
+ Tokens
+ Created
+
+
+
+ {items.map((item) => (
+ handleRowClick(item)}
+ >
+
+
+ {FEATURE_LABELS[item.feature] ?? item.feature}
+
+
+
+ {item.datasource_id || (
+ —
+ )}
+
+
+ {PROVIDER_LABELS[item.provider] ?? item.provider}
+
+
+ {item.tokens_used != null ? (
+ ~{item.tokens_used.toLocaleString()}
+ ) : (
+ —
+ )}
+
+
+ {timeAgo(item.created_at)}
+
+
+ ))}
+
+
+
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+ Page {page + 1} of {totalPages} · {total} entries
+
+
+ setPage((p) => Math.max(0, p - 1))}
+ >
+
+
+ = totalPages - 1}
+ onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
+ >
+
+
+
+
+ )}
+ >
+ )}
+
+
+
+ {/* Detail dialog */}
+ {
+ setDetailOpen(next);
+ if (!next) setDetailEntry(null);
+ }}
+ entry={detailLoading ? null : detailEntry}
+ onDelete={handleDelete}
+ />
+ >
+ );
+}
diff --git a/src/features/ai/components/AIResultDialog.tsx b/src/features/ai/components/AIResultDialog.tsx
new file mode 100644
index 0000000..f17f247
--- /dev/null
+++ b/src/features/ai/components/AIResultDialog.tsx
@@ -0,0 +1,289 @@
+import { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Loader2, Bot, AlertCircle, Sparkles, Copy, Check, RefreshCw } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface AIResultDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ description?: string;
+ markdown?: string;
+ loading?: boolean;
+ error?: string | null;
+ /** Whether the result came from cache. */
+ cached?: boolean;
+ /** ISO timestamp when the cached result was originally generated. */
+ createdAt?: string;
+ /** Callback to force a fresh AI call (skip cache). */
+ onReanalyze?: () => void;
+}
+
+/**
+ * Format a relative time string from an ISO date.
+ */
+function timeAgo(isoDate: string): string {
+ const diff = Date.now() - new Date(isoDate).getTime();
+ const seconds = Math.floor(diff / 1000);
+ if (seconds < 60) return "just now";
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ if (days < 30) return `${days}d ago`;
+ const months = Math.floor(days / 30);
+ return `${months}mo ago`;
+}
+
+/**
+ * Reusable dialog that renders AI-generated markdown output.
+ * Used by Schema Analysis and Query Explanation features.
+ */
+export function AIResultDialog({
+ open,
+ onOpenChange,
+ title,
+ description,
+ markdown,
+ loading,
+ error,
+ cached,
+ createdAt,
+ onReanalyze,
+}: AIResultDialogProps) {
+ return (
+
+
+
+
+
+
+
+ {title}
+ {/* Cached / Fresh badge */}
+ {markdown && !loading && !error && cached !== undefined && (
+
+ {cached ? "Cached" : "Fresh"}
+
+ )}
+
+ {/* Description row with optional timestamp + re-analyze */}
+
+ {description && (
+ {description}
+ )}
+ {markdown && !loading && cached && createdAt && (
+
+ Generated {timeAgo(createdAt)}
+
+ )}
+ {markdown && !loading && cached && onReanalyze && (
+
+
+ Re-analyze
+
+ )}
+
+
+
+
+ {loading ? (
+
+
+
+
+
Analyzing with AI…
+
+ ) : error ? (
+
+
+
Analysis failed
+
+ {error}
+
+
+ Make sure your AI provider is configured in{" "}
+ Settings → AI Settings .
+
+
+ ) : markdown ? (
+
+ ) : (
+
+
+
+
+
No content yet.
+
+ )}
+
+
+
+ );
+}
+
+/**
+ * Lightweight markdown renderer — no heavy library needed.
+ * Handles headings, bold, code blocks, bullet lists, and paragraphs.
+ *
+ * Exported so the history detail dialog can reuse it.
+ */
+export function MarkdownRenderer({ content }: { content: string }) {
+ const lines = content.split("\n");
+ const elements: React.ReactNode[] = [];
+ let i = 0;
+
+ while (i < lines.length) {
+ const line = lines[i];
+
+ // Fenced code block
+ if (line.trim().startsWith("```")) {
+ const lang = line.trim().slice(3).trim();
+ const codeLines: string[] = [];
+ i++;
+ while (i < lines.length && !lines[i].trim().startsWith("```")) {
+ codeLines.push(lines[i]);
+ i++;
+ }
+ elements.push(
+
+ );
+ i++;
+ continue;
+ }
+
+ // H1–H3 headings
+ const h3 = line.match(/^###\s+(.+)$/);
+ const h2 = line.match(/^##\s+(.+)$/);
+ const h1 = line.match(/^#\s+(.+)$/);
+ if (h1) {
+ elements.push({renderInline(h1[1])} );
+ i++; continue;
+ }
+ if (h2) {
+ elements.push({renderInline(h2[1])} );
+ i++; continue;
+ }
+ if (h3) {
+ elements.push({renderInline(h3[1])} );
+ i++; continue;
+ }
+
+ // Bullet list items
+ const bullet = line.match(/^[-*]\s+(.+)$/);
+ if (bullet) {
+ const items: string[] = [bullet[1]];
+ i++;
+ while (i < lines.length && lines[i].match(/^[-*]\s+(.+)$/)) {
+ items.push(lines[i].match(/^[-*]\s+(.+)$/)![1]);
+ i++;
+ }
+ elements.push(
+
+ {items.map((item, idx) => (
+
+ {renderInline(item)}
+
+ ))}
+
+ );
+ continue;
+ }
+
+ // Blank line
+ if (!line.trim()) {
+ i++;
+ continue;
+ }
+
+ // Paragraph
+ elements.push(
+
+ {renderInline(line)}
+
+ );
+ i++;
+ }
+
+ return {elements}
;
+}
+
+/** Render bold (**text**) and inline code (`code`) within a text node. */
+function renderInline(text: string): React.ReactNode {
+ const parts = text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
+ return parts.map((part, i) => {
+ if (part.startsWith("**") && part.endsWith("**")) {
+ return {part.slice(2, -2)} ;
+ }
+ if (part.startsWith("`") && part.endsWith("`")) {
+ return {part.slice(1, -1)};
+ }
+ return part;
+ });
+}
+
+function CodeBlock({ code, lang }: { code: string; lang: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error("Failed to copy code: ", err);
+ }
+ };
+
+ return (
+
+
+ {code}
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/features/ai/hooks/useAISettings.ts b/src/features/ai/hooks/useAISettings.ts
new file mode 100644
index 0000000..92045d3
--- /dev/null
+++ b/src/features/ai/hooks/useAISettings.ts
@@ -0,0 +1,22 @@
+import { loadAISettings, type AISettings } from "@/services/bridge/ai";
+import { useEffect, useState } from "react";
+
+/**
+ * Reads AISettings from localStorage and stays in sync when
+ * the user updates them in the Settings page during the same session.
+ */
+export function useAISettings(): AISettings {
+ const [settings, setSettings] = useState(loadAISettings);
+
+ useEffect(() => {
+ const onStorage = (e: StorageEvent) => {
+ if (e.key === "relwave:ai-settings") {
+ setSettings(loadAISettings());
+ }
+ };
+ window.addEventListener("storage", onStorage);
+ return () => window.removeEventListener("storage", onStorage);
+ }, []);
+
+ return settings;
+}
diff --git a/src/features/chart/components/ChartVisualization.tsx b/src/features/chart/components/ChartVisualization.tsx
index 8f1245d..f470f4f 100644
--- a/src/features/chart/components/ChartVisualization.tsx
+++ b/src/features/chart/components/ChartVisualization.tsx
@@ -6,6 +6,7 @@ import {
ChevronDown,
Sparkles,
AlertCircle,
+ Bot,
} from "lucide-react";
import {
DropdownMenu,
@@ -19,6 +20,10 @@ import ChartRenderer from "./ChartRenderer";
import { useChartVisualization } from "../hooks/useChartVisualization";
import { SelectedTable } from "@/features/database/types";
import { cn } from "@/lib/utils";
+import { useState } from "react";
+import { useAISettings } from "@/features/ai/hooks/useAISettings";
+import { aiService } from "@/services/bridge/ai";
+import { toast } from "sonner";
interface ChartVisualizationProps {
selectedTable: SelectedTable;
@@ -29,6 +34,9 @@ export const ChartVisualization = ({
selectedTable,
dbId,
}: ChartVisualizationProps) => {
+ const aiSettings = useAISettings();
+ const [aiLoading, setAiLoading] = useState(false);
+
const {
handleExport,
chartType,
@@ -45,6 +53,29 @@ export const ChartVisualization = ({
rowData,
} = useChartVisualization(selectedTable, dbId);
+ const handleAISuggest = async () => {
+ if (!columnData.length) return;
+ setAiLoading(true);
+ try {
+ const rec = await aiService.recommendChart(aiSettings, {
+ tableName: selectedTable?.name ?? "table",
+ columns: columnData.map((c) => ({
+ name: c.name,
+ type: c.type,
+ isPrimaryKey: c.isPrimaryKey,
+ })),
+ });
+ setChartType(rec.chartType);
+ setXAxis(rec.xAxis);
+ setYAxis(rec.yAxis);
+ toast.success("AI suggestion applied", { description: rec.reasoning });
+ } catch (err: any) {
+ toast.error("AI suggestion failed", { description: err?.message ?? String(err) });
+ } finally {
+ setAiLoading(false);
+ }
+ };
+
const hasData = rowData.length > 0;
const isReady = !isExecuting && !errorMessage && hasData;
@@ -72,6 +103,22 @@ export const ChartVisualization = ({
)}
+ {/* AI Suggest button */}
+
+ {aiLoading ? (
+
+ ) : (
+
+ )}
+ AI Suggest
+
+
Pending
- {local.length - applied.length}
+ {allMigrations.filter(m => m.status === "pending").length}
@@ -184,15 +185,7 @@ function MigrationItem({ migration, onApply, onRollback, onDelete, onViewSQL }:
{migration.appliedAt && (
- Applied: {new Date(migration.appliedAt).toLocaleString('en-IN', {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- hour12: true,
- timeZone: 'Asia/Kolkata'
- })}
+ Applied: {formatTimestamp(migration.appliedAt)}
)}
diff --git a/src/features/git/components/GitStatusPanel.tsx b/src/features/git/components/GitStatusPanel.tsx
index 3a1df09..8108c12 100644
--- a/src/features/git/components/GitStatusPanel.tsx
+++ b/src/features/git/components/GitStatusPanel.tsx
@@ -14,6 +14,8 @@ import {
Eye,
RotateCcw,
User,
+ RefreshCw,
+ UploadCloud,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -41,6 +43,7 @@ import { Spinner } from "@/components/ui/spinner";
import { toast } from "sonner";
import type { GitFileChange, GitLogEntry } from "@/features/git/types";
import { gitService } from "@/services/bridge/git";
+import { projectService } from "@/services/bridge/project";
import { GitHistoryGraph } from "./GitHistoryGraph";
// ─── Helpers ──────────────────────────────────────────
@@ -100,11 +103,12 @@ function timeAgo(dateStr: string): string {
interface GitStatusPanelProps {
projectDir: string | null | undefined;
+ projectId?: string;
}
-export default function GitStatusPanel({ projectDir }: GitStatusPanelProps) {
- const { data: status, isLoading: statusLoading } = useGitStatus(projectDir);
- const { data: changes } = useGitChanges(
+export default function GitStatusPanel({ projectDir, projectId }: GitStatusPanelProps) {
+ const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useGitStatus(projectDir);
+ const { data: changes, refetch: refetchChanges } = useGitChanges(
status?.isGitRepo ? projectDir : undefined
);
const { data: logGraph } = useGitLogGraph(
@@ -121,6 +125,9 @@ export default function GitStatusPanel({ projectDir }: GitStatusPanelProps) {
const [diffFile, setDiffFile] = useState("");
const [diffLoading, setDiffLoading] = useState(false);
+ const [syncing, setSyncing] = useState(false);
+ const [pushing, setPushing] = useState(false);
+
if (!projectDir) {
return (
@@ -176,11 +183,41 @@ export default function GitStatusPanel({ projectDir }: GitStatusPanelProps) {
);
};
+ const handleSync = async () => {
+ if (!projectId) return;
+ setSyncing(true);
+ try {
+ await projectService.syncMigrations(projectId);
+ toast.success("Migrations synced to git");
+ refetchStatus();
+ refetchChanges();
+ } catch (err: any) {
+ toast.error(`Failed to sync migrations: ${err.message}`);
+ } finally {
+ setSyncing(false);
+ }
+ };
+
+ const handlePush = async () => {
+ if (!projectId) return;
+ setPushing(true);
+ try {
+ await projectService.pushMigrations(projectId);
+ toast.success("Migrations pushed to remote");
+ refetchStatus();
+ refetchChanges();
+ } catch (err: any) {
+ toast.error(`Failed to push migrations: ${err.message}`);
+ } finally {
+ setPushing(false);
+ }
+ };
+
return (
{/* Header */}
-
+
Git Status
@@ -211,6 +248,23 @@ export default function GitStatusPanel({ projectDir }: GitStatusPanelProps) {
)}
+ {projectId && (
+
+
+ Migration Sync
+
+
+
+ {syncing ? : }
+ Sync to Git
+
+
+ {pushing ? : }
+ Push
+
+
+
+ )}
{/* Tabs */}
@@ -354,37 +408,41 @@ export default function GitStatusPanel({ projectDir }: GitStatusPanelProps) {
{/* Diff Dialog */}
-
+
{diffFile}
-
+
{diffLoading ? (
-
+
) : (
-
- {diffContent.split("\n").map((line, i) => {
- let color = "text-foreground/80";
- if (line.startsWith("+") && !line.startsWith("+++"))
- color = "text-green-500";
- else if (line.startsWith("-") && !line.startsWith("---"))
- color = "text-red-500";
- else if (line.startsWith("@@"))
- color = "text-blue-400";
- return (
-
- {line}
-
- );
- })}
-
+
+
+ {diffContent.split("\n").map((line, i) => {
+ let color = "text-foreground/80";
+
+ if (line.startsWith("+") && !line.startsWith("+++"))
+ color = "text-green-500";
+ else if (line.startsWith("-") && !line.startsWith("---"))
+ color = "text-red-500";
+ else if (line.startsWith("@@"))
+ color = "text-blue-400";
+
+ return (
+
+ {line}
+
+ );
+ })}
+
+
)}
-
+
diff --git a/src/features/home/components/ConnectionDetails.tsx b/src/features/home/components/ConnectionDetails.tsx
index 338a7ec..d1fddf0 100644
--- a/src/features/home/components/ConnectionDetails.tsx
+++ b/src/features/home/components/ConnectionDetails.tsx
@@ -1,5 +1,6 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { DatabaseConnection } from "@/features/database/types"
+import { formatTimestamp } from "@/lib/utils"
export function ConnectionDetails({ database }: { database: DatabaseConnection }) {
return (
@@ -41,7 +42,7 @@ export function ConnectionDetails({ database }: { database: DatabaseConnection }
Created
- {new Date(database.createdAt).toLocaleDateString()}
+ {formatTimestamp(database.createdAt)}
diff --git a/src/features/home/components/ConnectionList.tsx b/src/features/home/components/ConnectionList.tsx
index 0b77b85..52c2acb 100644
--- a/src/features/home/components/ConnectionList.tsx
+++ b/src/features/home/components/ConnectionList.tsx
@@ -1,5 +1,5 @@
import React, { useState, useMemo } from "react";
-import { Plus, Database, Search, Trash2, Zap, Folder, ChevronRight, ChevronDown, MoreVertical, Edit2, FolderPlus, GripVertical, FolderMinus, Shield } from "lucide-react";
+import { Plus, Database, Search, Trash2, Zap, Folder, ChevronRight, ChevronDown, MoreVertical, Edit2, FolderPlus, GripVertical, FolderMinus, Shield, FolderInput, X, Settings as SettingsIcon, CircleDot } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
@@ -48,6 +48,8 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { DatabaseConnection } from "@/features/database/types";
import { InputDialog } from "@/components/shared/InputDialog";
+import { SettingsDialog } from "@/features/settings/components";
+import { UnlinkedProjectItem } from "@/features/project/components/UnlinkedProjectItem";
// --- Components ---
@@ -134,7 +136,7 @@ function DraggableConnectionItem({
@@ -286,22 +288,26 @@ function DroppableGroup({
// --- Main Component ---
-export function ConnectionList({
- databases,
- filteredDatabases,
- loading,
- searchQuery,
- setSearchQuery,
- selectedDb,
- setSelectedDb,
- status,
- connectedCount,
- totalTables,
+export function ConnectionList({
+ databases,
+ filteredDatabases,
+ unlinkedProjects = [],
+ loading,
+ searchQuery,
+ setSearchQuery,
+ onlineFilter,
+ setOnlineFilter,
+ selectedDb,
+ setSelectedDb,
+ status,
+ connectedCount,
+ totalTables,
statsLoading,
onAddClick,
onDatabaseHover,
onDelete,
onTest,
+ onImportClick
}: ConnectionListProps) {
const {
groups,
@@ -320,6 +326,7 @@ export function ConnectionList({
const [newGroupOpen, setNewGroupOpen] = useState(false);
const [renameGroupOpen, setRenameGroupOpen] = useState(false);
const [groupToRename, setGroupToRename] = useState
(null);
+ const [settingsOpen, setSettingsOpen] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -377,6 +384,18 @@ export function ConnectionList({
New Group
+ {onImportClick && (
+
+
+
+
+
+
+
+ Import Project
+
+
+ )}
setSearchQuery(e.target.value)}
- className="h-8 pl-8 text-xs bg-background/65 border-border/60 shadow-inner"
+ className="h-8 pl-8 text-xs bg-background/65 border-border/60 shadow-inner focus-visible:ring-1 focus-visible:ring-primary/50"
/>
+
+ {onlineFilter && (
+
+
+
+ Filtered: Online only
+ setOnlineFilter(false)}
+ className="ml-1 hover:text-emerald-700 transition-colors"
+ >
+
+
+
+
+ )}
{/* Database List */}
@@ -446,11 +481,13 @@ export function ConnectionList({
"mt-2 pt-2 border-t border-border/20 rounded-lg transition-colors border border-transparent min-h-20 pb-4",
isOverUngrouped ? "bg-primary/5 border-primary/20 shadow-inner" : ""
)}>
-
-
- Ungrouped
-
-
+ {groups.length > 0 && (
+
+
+ ungrouped
+
+
+ )}
c.id)} strategy={verticalListSortingStrategy}>
{ungroupedConnections.map(db => (
@@ -470,6 +507,25 @@ export function ConnectionList({
+ {/* Unlinked Projects Section */}
+ {unlinkedProjects && unlinkedProjects.length > 0 && (
+
+
+
+ unlinked projects
+
+
+
+ {unlinkedProjects.map((project) => (
+
+ ))}
+
+
+ )}
+
{filteredDatabasesById.get(activeId)?.name}
@@ -499,7 +555,7 @@ export function ConnectionList({
{/* Quick Stats Footer */}
-
+
@@ -517,6 +573,11 @@ export function ConnectionList({
+
+ setSettingsOpen(true)} className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground">
+
+
+
{/* Dialogs */}
@@ -540,6 +601,8 @@ export function ConnectionList({
onConfirm={(name) => renameGroup(groupToRename.id, name)}
/>
)}
+
+
);
}
diff --git a/src/features/home/components/DatabaseDetail.tsx b/src/features/home/components/DatabaseDetail.tsx
index 0978050..5b3d699 100644
--- a/src/features/home/components/DatabaseDetail.tsx
+++ b/src/features/home/components/DatabaseDetail.tsx
@@ -9,17 +9,22 @@ import {
Trash2,
CircleDot,
ArrowLeft,
+ Download,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { DatabaseDetailProps } from "../types";
import { DatabaseOverviewPanel } from "./DatabaseOverviewPanel";
+import { useProjectByDatabaseId, useProjectSchema, useProjectERDiagram, useProjectQueries } from "@/features/project/hooks/useProjectQueries";
+import { projectService } from "@/services/bridge/project";
+import { toast } from "sonner";
const DB_COLORS: Record
= {
postgresql: { bg: "bg-blue-500/10", text: "text-blue-500" },
@@ -42,6 +47,35 @@ export function DatabaseDetail({
size,
tables
}: DatabaseDetailProps) {
+ // Fetch linked project and its sub-resources
+ const { data: project } = useProjectByDatabaseId(database.id);
+ const { data: schemaData } = useProjectSchema(project?.id);
+ const { data: erData } = useProjectERDiagram(project?.id);
+ const { data: queriesData } = useProjectQueries(project?.id);
+
+ const handleExport = async () => {
+ if (!project?.id) return;
+ try {
+ const bundle = await projectService.exportProject(project.id);
+ if (!bundle) {
+ toast.error("Project not found");
+ return;
+ }
+ const blob = new Blob([JSON.stringify(bundle, null, 2)], {
+ type: "application/json",
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${bundle.metadata.name.replace(/\s+/g, "-").toLowerCase()}-export.json`;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 0);
+ toast.success("Project exported");
+ } catch (err: any) {
+ toast.error("Export failed", { description: err.message });
+ }
+ };
+
return (
{/* Detail Header */}
@@ -115,6 +149,15 @@ export function DatabaseDetail({
+ {project && (
+ <>
+
+
+ Export Project
+
+
+ >
+ )}
0}
+ queryCount={queriesData?.queries?.length}
/>
diff --git a/src/features/home/components/DatabaseOverviewPanel.tsx b/src/features/home/components/DatabaseOverviewPanel.tsx
index e317feb..7ede4e4 100644
--- a/src/features/home/components/DatabaseOverviewPanel.tsx
+++ b/src/features/home/components/DatabaseOverviewPanel.tsx
@@ -1,21 +1,30 @@
-import { Card, CardAction, CardContent, CardHeader } from "@/components/ui/card";
-import { Clock, Database, HardDrive, Table2 } from "lucide-react";
-import { formatRelativeTime } from "../utils";
+import { Card, CardAction, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
+import { Clock, Database, HardDrive, Table2, Layers, GitBranch, FileCode2 } from "lucide-react";
+import { formatRelativeTime } from "@/lib/utils";
import { DatabaseConnection } from "@/features/database/types";
import { ConnectionDetails } from "./ConnectionDetails";
-
+import { MigrationStatusCard } from "./MigrationStatusCard";
interface DatabaseOverviewPanelProps {
database: DatabaseConnection;
+ projectId?: string;
tables: number | string | undefined;
size: string | number | undefined;
+ // Project data props
+ schemaCount?: number;
+ hasERLayout?: boolean;
+ queryCount?: number;
}
export function DatabaseOverviewPanel({
database,
+ projectId,
size,
- tables
+ tables,
+ schemaCount,
+ hasERLayout,
+ queryCount,
}: DatabaseOverviewPanelProps) {
return (
@@ -69,6 +78,102 @@ export function DatabaseOverviewPanel({
+ {/* Project Data */}
+ {projectId && (
+ <>
+ Project Data
+
+
+
+
+
+
+
+
+ ER Diagram
+
+
+ {hasERLayout ? "Saved" : "—"}
+
+
+ Diagram layout
+
+
+
+
+
+
+
+
+ Saved Queries
+
+
+ {queryCount ?? "—"}
+
+
+ Stored queries
+
+
+
+ >
+ )}
+
+ {!projectId && (
+ <>
+ Project Data
+
+
+
+
+
+
+ Schema Cache
+
+
+ {schemaCount ?? "—"}
+
+
+ Cached schemas
+
+
+
+
+
+
+
+
+ ER Diagram
+
+
+ {hasERLayout ? "Saved" : "—"}
+
+
+ Diagram layout
+
+
+
+
+
+
+
+
+ Saved Queries
+
+
+ {queryCount ?? "—"}
+
+
+ Stored queries
+
+
+
+ >
+ )}
+
{/* Connection Details */}
diff --git a/src/features/home/components/DeleteConnectionDialog.tsx b/src/features/home/components/DeleteConnectionDialog.tsx
new file mode 100644
index 0000000..5dbdd16
--- /dev/null
+++ b/src/features/home/components/DeleteConnectionDialog.tsx
@@ -0,0 +1,167 @@
+import React, { useState, useEffect } from "react";
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Label } from "@/components/ui/label";
+
+type DeleteConnectionDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ connectionName: string;
+ projectName: string;
+ hasGitRemote: boolean;
+ gitRemoteUrl?: string;
+ onConfirm: (choice: "unlink" | "delete_project") => Promise;
+};
+
+export function DeleteConnectionDialog({
+ open,
+ onOpenChange,
+ connectionName,
+ projectName,
+ hasGitRemote,
+ gitRemoteUrl,
+ onConfirm,
+}: DeleteConnectionDialogProps) {
+ const [step, setStep] = useState<1 | 2>(1);
+ const [choice, setChoice] = useState<"unlink" | "delete_project">("unlink");
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ // Reset state when dialog opens
+ useEffect(() => {
+ if (open) {
+ setStep(1);
+ setChoice("unlink");
+ setIsDeleting(false);
+ }
+ }, [open]);
+
+ const handleNext = async () => {
+ if (choice === "unlink") {
+ // Execute unlink flow directly, no second step
+ setIsDeleting(true);
+ try {
+ await onConfirm("unlink");
+ } finally {
+ setIsDeleting(false);
+ }
+ } else {
+ // Proceed to step 2 for project deletion confirmation
+ setStep(2);
+ }
+ };
+
+ const handleConfirmDelete = async () => {
+ setIsDeleting(true);
+ try {
+ await onConfirm("delete_project");
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ return (
+
+
+
+ {step === 1 ? (
+ <>
+ Delete Connection
+
+ {connectionName}
+
+ >
+ ) : (
+
+ Are you absolutely sure?
+
+ )}
+
+
+
+ {step === 1 ? (
+
+
+ This connection is linked to the project{" "}
+ "{projectName}" .
+
+
What would you like to do with the project?
+
setChoice(val)}
+ className="space-y-3 mt-4"
+ >
+ setChoice("unlink")}>
+
+
+ Keep project (unlink connection only)
+
+ Schemas, queries and migrations are kept
+
+
+
+ setChoice("delete_project")}>
+
+
+ Delete project as well
+
+ Permanently removes all project data including migrations and diagrams
+
+
+
+
+
+ ) : (
+
+
+ This will permanently delete:
+
+
+ migrations/
+ schema.json
+ diagrams/
+ All saved queries
+
+
+ This cannot be undone.
+
+
+ {hasGitRemote && gitRemoteUrl && (
+
+
+ ⚠ Note
+
+
+ The remote repository at {gitRemoteUrl} will not be deleted. Only the local project folder will be removed.
+
+
+ )}
+
+ )}
+
+
+
+ {step === 1 ? (
+ <>
+ onOpenChange(false)} disabled={isDeleting}>
+ Cancel
+
+
+ {isDeleting ? "Processing..." : "Confirm Delete"}
+
+ >
+ ) : (
+ <>
+ setStep(1)} disabled={isDeleting}>
+ Back
+
+
+ {isDeleting ? "Deleting..." : "Delete Everything"}
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/features/home/components/DiscoveredDatabasesCard.tsx b/src/features/home/components/DiscoveredDatabasesCard.tsx
index be8a081..e912f59 100644
--- a/src/features/home/components/DiscoveredDatabasesCard.tsx
+++ b/src/features/home/components/DiscoveredDatabasesCard.tsx
@@ -3,11 +3,12 @@ import { Radar, Plus, Container, Monitor, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useDiscoveredDatabases } from "@/features/database/hooks/useDiscoveredDatabases";
-import { DiscoveredDatabase } from "@/features/database/types";
+import { DatabaseConnection, DiscoveredDatabase } from "@/features/database/types";
import { Card, CardAction, CardContent, CardDescription, CardTitle } from "@/components/ui/card";
interface DiscoveredDatabasesCardProps {
onAddDatabase: (db: DiscoveredDatabase) => void;
+ existingConnections: DatabaseConnection[];
}
const DB_TYPE_COLORS = {
@@ -35,6 +36,7 @@ const DB_TYPE_COLORS = {
export function DiscoveredDatabasesCard({
onAddDatabase,
+ existingConnections,
}: DiscoveredDatabasesCardProps) {
const { databases, isScanning, scan, lastScanned } = useDiscoveredDatabases();
@@ -86,8 +88,9 @@ export function DiscoveredDatabasesCard({
return (
@@ -107,7 +110,7 @@ export function DiscoveredDatabasesCard({
{/* Details */}
-
+
{db.containerName && (
-
+
Container: {db.containerName}
)}
-
-
- Suggested: {db.suggestedName}
-
+
+
+
+ Suggested: {db.suggestedName}
+
+
+ {existingConnections.some(c => c.host === db.host && String(c.port) === String(db.port)) ? (
+
Already added
+ ) : (
+
onAddDatabase(db)}
+ >
+ Add
+
+ )}
+
-
- {/* Add Button */}
- onAddDatabase(db)}
- title="Add this connection"
- >
-
-
);
diff --git a/src/features/home/components/MigrationStatusCard.tsx b/src/features/home/components/MigrationStatusCard.tsx
new file mode 100644
index 0000000..a9e3a6d
--- /dev/null
+++ b/src/features/home/components/MigrationStatusCard.tsx
@@ -0,0 +1,110 @@
+import { useState } from "react";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { useImportAnalysis } from "@/features/project/hooks/useImportAnalysis";
+import { AlertTriangle, Database, RefreshCw, CheckCircle2 } from "lucide-react";
+import { SchemaDriftSheet } from "@/features/project/components/SchemaDriftSheet";
+import { Skeleton } from "@/components/ui/skeleton";
+import { cn } from "@/lib/utils";
+
+interface MigrationStatusCardProps {
+ projectId: string;
+ databaseId: string;
+ connectionName: string;
+}
+
+export function MigrationStatusCard({ projectId, databaseId, connectionName }: MigrationStatusCardProps) {
+ const { analysis, loading, refetch } = useImportAnalysis(projectId, databaseId);
+ const [driftSheetOpen, setDriftSheetOpen] = useState(false);
+
+ if (loading && !analysis) {
+ return ;
+ }
+
+ if (!analysis) return null;
+
+ const isDrifted = analysis.driftStatus === "drifted";
+ const isSynced = analysis.driftStatus === "synced";
+
+ return (
+ <>
+
+
+
+
+
+ Migration Status
+
+ {isSynced ? (
+
+
+ Synced
+
+ ) : isDrifted ? (
+
+
+ Drift Detected
+
+ ) : (
+
+ {Math.max(0, analysis.migrationCount)} Pending
+
+ )}
+
+
+ State of the live database against the project schema.
+
+
+
+
+
+ Pending Migrations
+ {Math.max(0, analysis.migrationCount)}
+
+
+ {isDrifted && analysis.driftDetails && (
+
+ Schema drift detected. The live database has modifications not tracked in the schema snapshot.
+
+ )}
+
+ {isDrifted && !analysis.driftDetails && (
+
+ Drift detected — pending migrations have not been applied to the live database.
+
+ )}
+
+
+
+
+ {loading ? "Refreshing..." : "Refresh"}
+
+ {isDrifted && (
+ setDriftSheetOpen(true)} className="flex-1">
+ View Drift
+
+ )}
+
+
+
+
+
+ {/* Always render the sheet — it manages its own empty-state guard */}
+
+ >
+ );
+}
diff --git a/src/features/home/components/WelcomeView.tsx b/src/features/home/components/WelcomeView.tsx
index cb97e87..e18aa57 100644
--- a/src/features/home/components/WelcomeView.tsx
+++ b/src/features/home/components/WelcomeView.tsx
@@ -1,3 +1,4 @@
+import { useState } from "react";
import {
Plus,
Database,
@@ -9,12 +10,18 @@ import {
Layers,
} from "lucide-react";
import { Button } from "@/components/ui/button";
-import { cn } from "@/lib/utils";
+import { cn, formatRelativeTime } from "@/lib/utils";
import { WelcomeViewProps } from "../types";
-import { formatRelativeTime } from "../utils";
import { DiscoveredDatabasesCard } from "./DiscoveredDatabasesCard";
import { Spinner } from "@/components/ui/spinner";
import { Card, CardAction, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
+import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { useDbStats, useTables } from "@/features/project/hooks/useDbQueries";
+import { bytesToMBString } from "@/lib/bytesToMB";
+import { DatabaseConnection } from "@/features/database/types";
+import { useCountUp } from "@/hooks/useCountUp";
const DB_COLORS: Record = {
@@ -28,6 +35,44 @@ function getDbColors(type: string) {
return DB_COLORS[type] || { bg: "bg-primary/10", text: "text-primary" };
}
+function ConnectionSizeItem({ db }: { db: DatabaseConnection }) {
+ const { data: stats, isLoading } = useDbStats(db.id);
+ const sizeStr = stats?.sizeBytes ? bytesToMBString(stats.sizeBytes) : "—";
+ return (
+
+
+
+ {db.name}
+
+
{isLoading ? "..." : sizeStr}
+
+ );
+}
+
+function ConnectionTablesList({ db }: { db: DatabaseConnection }) {
+ const { data: tables, isLoading } = useTables(db.id);
+ if (isLoading) return Loading tables for {db.name}...
;
+ if (!tables || tables.length === 0) return null;
+ return (
+
+
+
+ {tables.map(t => (
+
+
+ {t.schema !== 'public' && t.schema !== db.database ? `${t.schema}.` : ''}{t.name}
+
+ ))}
+
+
+ );
+}
+
export function WelcomeView({
databases,
recentDatabases,
@@ -36,40 +81,66 @@ export function WelcomeView({
totalTables,
totalSize,
statsLoading,
- welcomeMessage,
onAddClick,
onSelectDb,
onDatabaseHover,
onDiscoveredDatabaseAdd,
+ onOnlineFilterClick,
}: WelcomeViewProps) {
+ const [showAllActivity, setShowAllActivity] = useState(false);
+
+ const hour = new Date().getHours();
+ let timeGreeting = "Good morning";
+ if (hour >= 12 && hour < 17) timeGreeting = "Good afternoon";
+ else if (hour >= 17) timeGreeting = "Good evening";
+
+ const lastActiveName = recentDatabases[0]?.name;
+
+ const animatedConnections = useCountUp(databases.length);
+ const animatedOnline = useCountUp(connectedCount);
+ const animatedTables = useCountUp(typeof totalTables === 'number' ? totalTables : 0);
return (
-
- {/* Welcome Header */}
-
-
-
-
-
-
-
- {welcomeMessage}
-
-
-
- Select a connection or add a new one
-
-
+
+ {/* Welcome Header (Subtle Greeting) */}
+
+ {databases.length === 0 ? (
+ <>
+ {timeGreeting}
+ •
+
+ Add your first connection
+
+ >
+ ) : (
+ <>
+ {timeGreeting}
+ •
+ {connectedCount} connections online
+ {lastActiveName && (
+ <>
+ •
+ Last active: {lastActiveName}
+ >
+ )}
+ >
+ )}
{/* Stats Overview */}
-
+ document.getElementById('connection-search')?.focus()}
+ >
Total Connections
- {databases.length}
+ {animatedConnections}
@@ -78,12 +149,15 @@ export function WelcomeView({
-
+
Online Now
- {connectedCount}
+ {animatedOnline}
@@ -92,109 +166,169 @@ export function WelcomeView({
-
-
-
-
Total Tables
-
- {statsLoading ? (
-
-
-
- ) : (
- totalTables
- )}
-
-
-
-
-
-
-
+
+
+
+
+
+
Total Tables
+
+ {statsLoading ? (
+
+
+
+ ) : (
+ typeof totalTables === 'number' ? animatedTables : totalTables
+ )}
+
+
+
+
+
+
+
+
+
+
+ All Tables
+
+
+ {databases.length === 0 ? (
+ No databases connected.
+ ) : (
+ databases.map(db => )
+ )}
+
+
+
-
-
-
-
Data Size
-
- {statsLoading ? (
-
-
-
- ) : (
- totalSize
- )}
-
-
-
-
-
-
-
+
+
+
+
+
+
Data Size
+
+ {statsLoading ? (
+
+
+
+ ) : (
+ totalSize
+ )}
+
+
+
+
+
+
+
+
+
+ {databases.length === 0 ? (
+ No databases connected.
+ ) : (
+ databases.map(db => )
+ )}
+
+
{/* Discovered Databases */}
{
onDiscoveredDatabaseAdd && (
-
+
)
}
{/* Recent Activity */}
{
- recentDatabases.length > 0 && (
-
-
-
-
- Recent Activity
-
-
-
- {recentDatabases.map((db, index) => {
- const isConnected = status.get(db.id) === "connected";
- return (
-
onSelectDb(db.id)}
- onMouseEnter={() => onDatabaseHover(db.id)}
- disabled={!isConnected}
- className={cn(
- "w-full h-auto justify-start flex items-center gap-4 px-4 py-3 rounded-none text-left transition-colors",
- index !== recentDatabases.length - 1 &&
- "border-b border-border/30",
- isConnected
- ? "hover:bg-accent/50 text-foreground"
- : "opacity-50 cursor-not-allowed"
- )}
- >
- 0 && (() => {
+ const itemsToShow = showAllActivity ? recentDatabases : recentDatabases.slice(0, 5);
+ const emptySlots = Math.max(0, 5 - itemsToShow.length);
+
+ return (
+
+
+
+
+ Recent Activity
+
+
+
+ {itemsToShow.map((db, index) => {
+ const isConnected = status.get(db.id) === "connected";
+ return (
+ onSelectDb(db.id)}
+ onMouseEnter={() => onDatabaseHover(db.id)}
+ disabled={!isConnected}
+ style={{ animationDelay: `${index * 50}ms`, animationFillMode: "both" }}
className={cn(
- "h-10 w-10 rounded-lg flex items-center justify-center shrink-0 ring-1 ring-border/50",
- getDbColors(db.type).bg
+ "motion-safe:animate-in fade-in slide-in-from-bottom-2 duration-300 w-full h-auto justify-start flex items-center gap-4 px-4 py-3 rounded-none text-left transition-colors",
+ (index !== itemsToShow.length - 1 || emptySlots > 0 || recentDatabases.length > 5) &&
+ "border-b border-border/30",
+ isConnected
+ ? "hover:bg-accent/50 text-foreground"
+ : "opacity-50 cursor-not-allowed"
)}
>
-
-
-
-
{db.name}
-
- {formatRelativeTime(db.lastAccessedAt)} • {db.type}
-
-
-
+ >
+
+
+
+
{db.name}
+
+ {db.type} • {db.host}:{db.port}
+
+
+
+ {formatRelativeTime(db.lastAccessedAt)}
+
+
+ );
+ })}
+
+ {/* Empty Padding Slots */}
+ {Array.from({ length: emptySlots }).map((_, i) => (
+
5) && "border-b border-border/30"
+ )}
+ />
+ ))}
+
+ {/* View All Toggle */}
+ {recentDatabases.length > 5 && (
+ setShowAllActivity(!showAllActivity)}
+ className="w-full h-10 rounded-none text-xs text-muted-foreground hover:text-foreground transition-colors"
+ >
+ {showAllActivity ? "Show less" : "View all \u2192"}
- );
- })}
+ )}
+
-
- )
+ );
+ })()
}
{/* Empty State */}
diff --git a/src/features/home/components/index.ts b/src/features/home/components/index.ts
index d183bf5..765f30b 100644
--- a/src/features/home/components/index.ts
+++ b/src/features/home/components/index.ts
@@ -3,6 +3,6 @@ export { DatabaseDetail } from "./DatabaseDetail";
export { WelcomeView } from "../components/WelcomeView";
export { AddConnectionDialog } from "./AddConnectionDialog";
export { DeleteDialog } from "./DeleteDialog";
+export { DeleteConnectionDialog } from "./DeleteConnectionDialog";
export { DiscoveredDatabasesCard } from "./DiscoveredDatabasesCard";
export * from "../types";
-export * from "../utils";
diff --git a/src/features/home/hooks/useDeleteConnection.ts b/src/features/home/hooks/useDeleteConnection.ts
new file mode 100644
index 0000000..c5f93c5
--- /dev/null
+++ b/src/features/home/hooks/useDeleteConnection.ts
@@ -0,0 +1,98 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { databaseService } from "@/services/bridge/database";
+import { bridgeRequest } from "@/services/bridge/bridgeClient";
+import { projectKeys } from "@/features/project/hooks/useProjectQueries";
+
+type DeleteConnectionDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ connectionName: string;
+ projectName: string;
+ projectPath: string;
+ hasGitRemote: boolean;
+ gitRemoteUrl?: string;
+ onConfirm: (choice: "unlink" | "delete_project") => Promise
;
+};
+
+export function useDeleteConnection(onSuccess?: () => void) {
+ const queryClient = useQueryClient();
+
+ // Core state
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [dialogProps, setDialogProps] = useState | null>(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ // Database deletion mutation (simple path or final step of complex path)
+ const deleteDatabaseMutation = useMutation({
+ mutationFn: databaseService.deleteDatabase,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["databases"] });
+ onSuccess?.();
+ },
+ });
+
+ const initiateDelete = async (databaseId: string, databaseName: string) => {
+ setIsDeleting(true);
+ try {
+ // 1. Check for linked project
+ const res = await bridgeRequest("project.getByDatabaseId", { databaseId });
+ const linkedProject = res?.data;
+
+ if (!linkedProject) {
+ // 2. No project -> call existing delete directly
+ await deleteDatabaseMutation.mutateAsync(databaseId);
+ toast.success("Database removed");
+ } else {
+ // 3. Project exists -> fetch git remote, open dialog
+ const projectPathRes = await bridgeRequest("project.getDir", { projectId: linkedProject.id });
+ const projectPath = projectPathRes?.data?.dir || "";
+
+ const gitRes = await bridgeRequest("project.getGitRemote", { projectPath });
+ const remoteUrl = gitRes?.data?.remoteUrl;
+
+ setDialogProps({
+ connectionName: databaseName,
+ projectName: linkedProject.name,
+ projectPath,
+ hasGitRemote: !!remoteUrl,
+ gitRemoteUrl: remoteUrl || undefined,
+ onConfirm: async (choice) => {
+ try {
+ if (choice === "unlink") {
+ await bridgeRequest("project.unlinkFromConnection", { databaseId });
+ } else {
+ await bridgeRequest("project.deleteWithConnection", { databaseId });
+ // Invalidate projects since one was just deleted
+ queryClient.invalidateQueries({ queryKey: projectKeys.all });
+ }
+
+ // Delete the DB connection itself
+ await deleteDatabaseMutation.mutateAsync(databaseId);
+ toast.success("Database removed");
+ setDialogOpen(false);
+ } catch (err: any) {
+ toast.error("Failed to delete", { description: err.message });
+ throw err; // throw to keep dialog open if error happens
+ }
+ }
+ });
+
+ setDialogOpen(true);
+ }
+ } catch (err: any) {
+ toast.error("Failed to initiate delete", { description: err.message });
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ return {
+ initiateDelete,
+ dialogOpen,
+ setDialogOpen,
+ dialogProps,
+ isDeleting: deleteDatabaseMutation.isPending || isDeleting,
+ };
+}
diff --git a/src/features/home/hooks/useIndexPage.ts b/src/features/home/hooks/useIndexPage.ts
index c237e0e..ac514a7 100644
--- a/src/features/home/hooks/useIndexPage.ts
+++ b/src/features/home/hooks/useIndexPage.ts
@@ -3,17 +3,22 @@
import { useState, useCallback, useMemo, useEffect } from "react";
import { toast } from "sonner";
import { useNavigate, useLocation } from "react-router-dom";
+import { useQueryClient } from "@tanstack/react-query";
import { useDatabases, useAddDatabase, useDeleteDatabase, usePrefetch } from "@/features/project/hooks/useDbQueries";
+import { projectKeys, useProjects } from "@/features/project/hooks/useProjectQueries";
import { ConnectionFormData, REQUIRED_FIELDS, SQLITE_REQUIRED_FIELDS } from "@/features/home/types";
import { useDatabaseStats } from "../../database/hooks/useDatabaseStats";
import { useSelectedDbStats } from "../../database/hooks/useSelectedDbStats";
import { databaseService } from "@/services/bridge/database";
+import { projectService } from "@/services/bridge/project";
+import { useDeleteConnection } from "./useDeleteConnection";
import { DatabaseConnection } from "@/features/database/types";
import { useWelcomeMessage } from "@/features/database/hooks/useWelcomeMessage";
export const useIndexPage = (bridgeReady: boolean) => {
const navigate = useNavigate();
const location = useLocation();
+ const queryClient = useQueryClient();
// ... existing logic ...
@@ -38,20 +43,18 @@ export const useIndexPage = (bridgeReady: boolean) => {
refetchStatus,
} = useDatabaseStats(bridgeReady, databases.length > 0);
- const welcomeMessage = useWelcomeMessage();
// Mutations
const addDatabaseMutation = useAddDatabase();
- const deleteDatabaseMutation = useDeleteDatabase();
const { prefetchTables, prefetchStats } = usePrefetch();
// UI state
const [searchQuery, setSearchQuery] = useState("");
+ const [onlineFilter, setOnlineFilter] = useState(false);
const [selectedDb, setSelectedDb] = useState(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
- const [dbToDelete, setDbToDelete] = useState<{ id: string; name: string } | null>(null);
const [prefilledConnectionData, setPrefilledConnectionData] = useState | undefined>(undefined);
+ const [isImportOpen, setIsImportOpen] = useState(false);
// Selected db derived state
const selectedDatabase = useMemo(
@@ -67,22 +70,33 @@ export const useIndexPage = (bridgeReady: boolean) => {
const filteredDatabases = useMemo(
() =>
databases.filter(
- (db: DatabaseConnection) =>
- db.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
- db.host.toLowerCase().includes(searchQuery.toLowerCase())
+ (db: DatabaseConnection) => {
+ const matchesSearch = db.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ db.host.toLowerCase().includes(searchQuery.toLowerCase());
+ const matchesOnline = onlineFilter ? status.get(db.id) === "connected" : true;
+ return matchesSearch && matchesOnline;
+ }
),
- [databases, searchQuery]
+ [databases, searchQuery, onlineFilter, status]
);
const recentDatabases = useMemo(
() =>
[...databases]
.filter((db) => db.lastAccessedAt)
- .sort((a, b) => new Date(b.lastAccessedAt!).getTime() - new Date(a.lastAccessedAt!).getTime())
- .slice(0, 5),
+ .sort((a, b) => new Date(b.lastAccessedAt!).getTime() - new Date(a.lastAccessedAt!).getTime()),
[databases]
);
+ // Projects list
+ const { data: projects = [] } = useProjects();
+
+ // Unlinked projects
+ const unlinkedProjects = useMemo(
+ () => projects.filter((p: any) => p.status === "unlinked" || !p.databaseId),
+ [projects]
+ );
+
// ---- Bridge Handlers ----
const handleAddDatabase = async (formData: ConnectionFormData) => {
@@ -145,7 +159,21 @@ export const useIndexPage = (bridgeReady: boolean) => {
};
}
- await addDatabaseMutation.mutateAsync(payload);
+ const db = await addDatabaseMutation.mutateAsync(payload);
+
+ // Auto-create a linked project so the user gets git, schema cache,
+ // and saved queries for free — one click, two things.
+ try {
+ await projectService.createProject({
+ databaseId: db.id,
+ name: db.name,
+ defaultSchema: db.type === "postgresql" ? "public" : undefined,
+ });
+ } catch {
+ // Non-fatal — project auto-creation shouldn't block the database add
+ console.warn("Auto-project creation failed for", db.name);
+ }
+
toast.success("Database connection added");
setIsDialogOpen(false);
await Promise.all([refetchDatabases(), refetchStatus()]);
@@ -154,20 +182,6 @@ export const useIndexPage = (bridgeReady: boolean) => {
}
};
- const handleDeleteDatabase = async () => {
- if (!dbToDelete) return;
- try {
- await deleteDatabaseMutation.mutateAsync(dbToDelete.id);
- toast.success("Database removed");
- setDeleteDialogOpen(false);
- setDbToDelete(null);
- if (selectedDb === dbToDelete.id) setSelectedDb(null);
- refetchDatabases();
- } catch (err: any) {
- toast.error("Failed to delete", { description: err.message });
- }
- };
-
const handleTestConnection = async (id: string, name: string) => {
try {
const result = await databaseService.testConnection(id);
@@ -196,11 +210,6 @@ export const useIndexPage = (bridgeReady: boolean) => {
// ---- Dialog Helpers ----
- const openDeleteDialog = (id: string, name: string) => {
- setDbToDelete({ id, name });
- setDeleteDialogOpen(true);
- };
-
const handleDiscoveredDatabaseAdd = useCallback(
(db: {
type: string;
@@ -232,15 +241,37 @@ export const useIndexPage = (bridgeReady: boolean) => {
if (!open) setPrefilledConnectionData(undefined);
};
+ // ---- Delete Hook ----
+ const {
+ initiateDelete,
+ dialogOpen: deleteDialogOpen,
+ setDialogOpen: setDeleteDialogOpen,
+ dialogProps: deleteConnectionDialogProps,
+ isDeleting
+ } = useDeleteConnection(() => {
+ if (selectedDb) setSelectedDb(null);
+ refetchDatabases();
+ });
+
+ const openDeleteDialog = (id: string, name: string) => {
+ initiateDelete(id, name);
+ };
+
+ const handleImportComplete = async (_projectId: string, _projectName: string) => {
+ setIsImportOpen(false);
+ queryClient.invalidateQueries({ queryKey: projectKeys.all });
+ await Promise.all([refetchDatabases(), refetchStatus()]);
+ };
+
return {
// Data
databases,
filteredDatabases,
recentDatabases,
+ unlinkedProjects,
selectedDatabase,
selectedDbStats,
loading,
- welcomeMessage,
// Status + stats
status,
@@ -256,23 +287,30 @@ export const useIndexPage = (bridgeReady: boolean) => {
// UI state
searchQuery,
setSearchQuery,
+ onlineFilter,
+ setOnlineFilter,
selectedDb,
setSelectedDb,
isDialogOpen,
setIsDialogOpen,
deleteDialogOpen,
setDeleteDialogOpen,
- dbToDelete,
+ deleteConnectionDialogProps,
+ isDeleting,
prefilledConnectionData,
// Handlers
handleAddDatabase,
- handleDeleteDatabase,
handleTestConnection,
handleDatabaseClick,
handleDatabaseHover,
handleDiscoveredDatabaseAdd,
handleDialogClose,
openDeleteDialog,
+
+ // Import
+ isImportOpen,
+ setIsImportOpen,
+ handleImportComplete,
};
};
\ No newline at end of file
diff --git a/src/features/home/types.ts b/src/features/home/types.ts
index 6543769..f197b97 100644
--- a/src/features/home/types.ts
+++ b/src/features/home/types.ts
@@ -3,9 +3,12 @@ import { DatabaseConnection, DiscoveredDatabase } from "@/features/database/type
export interface ConnectionListProps {
databases: DatabaseConnection[];
filteredDatabases: DatabaseConnection[];
+ unlinkedProjects?: any[];
loading: boolean;
searchQuery: string;
setSearchQuery: (query: string) => void;
+ onlineFilter: boolean;
+ setOnlineFilter: (filter: boolean) => void;
selectedDb: string | null;
setSelectedDb: (id: string | null) => void;
status: Map;
@@ -16,6 +19,9 @@ export interface ConnectionListProps {
onDatabaseHover: (dbId: string) => void;
onDelete: (dbId: string, dbName: string) => void;
onTest: (dbId: string, dbName: string) => void;
+ onImportClick?: () => void;
+ onRelinkProject?: (projectId: string, newDatabaseId: string) => void;
+ onDeleteProject?: (projectId: string) => void;
}
export interface DatabaseDetailProps {
@@ -37,11 +43,11 @@ export interface WelcomeViewProps {
totalTables: number | string;
totalSize: string;
statsLoading: boolean;
- welcomeMessage: string;
onAddClick: () => void;
onSelectDb: (id: string) => void;
onDatabaseHover: (dbId: string) => void;
onDiscoveredDatabaseAdd?: (db: DiscoveredDatabase) => void;
+ onOnlineFilterClick: () => void;
}
export interface AddConnectionDialogProps {
diff --git a/src/features/home/utils.ts b/src/features/home/utils.ts
deleted file mode 100644
index cd2b51f..0000000
--- a/src/features/home/utils.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * Format a date string to a relative time (e.g., "5m ago", "2h ago")
- */
-export function formatRelativeTime(dateString?: string): string {
- if (!dateString) return "Never";
- const date = new Date(dateString);
- const now = new Date();
- const diffMs = now.getTime() - date.getTime();
- const diffMins = Math.floor(diffMs / 60000);
- const diffHours = Math.floor(diffMs / 3600000);
- const diffDays = Math.floor(diffMs / 86400000);
- if (diffMins < 1) return "Just now";
- if (diffMins < 60) return `${diffMins}m ago`;
- if (diffHours < 24) return `${diffHours}h ago`;
- if (diffDays < 7) return `${diffDays}d ago`;
- return date.toLocaleDateString();
-}
diff --git a/src/features/project/components/CreateProjectDialog.tsx b/src/features/project/components/CreateProjectDialog.tsx
deleted file mode 100644
index 8aa54d0..0000000
--- a/src/features/project/components/CreateProjectDialog.tsx
+++ /dev/null
@@ -1,172 +0,0 @@
-import { useState } from "react";
-import { Database, Link as LinkIcon } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { Textarea } from "@/components/ui/textarea";
-import { DatabaseConnection } from "@/features/database/types";
-
-interface CreateProjectDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- onSubmit: (data: {
- databaseId: string;
- name: string;
- description?: string;
- defaultSchema?: string;
- }) => void;
- isLoading?: boolean;
- databases: DatabaseConnection[];
-}
-
-export function CreateProjectDialog({
- open,
- onOpenChange,
- onSubmit,
- isLoading,
- databases,
-}: CreateProjectDialogProps) {
- const [name, setName] = useState("");
- const [description, setDescription] = useState("");
- const [databaseId, setDatabaseId] = useState("");
- const [defaultSchema, setDefaultSchema] = useState("");
-
- const resetForm = () => {
- setName("");
- setDescription("");
- setDatabaseId("");
- setDefaultSchema("");
- };
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- if (!name.trim() || !databaseId) return;
-
- onSubmit({
- databaseId,
- name: name.trim(),
- description: description.trim() || undefined,
- defaultSchema: defaultSchema.trim() || undefined,
- });
-
- resetForm();
- };
-
- return (
- {
- onOpenChange(isOpen);
- if (!isOpen) resetForm();
- }}
- >
-
-
-
-
- Create Project
-
-
- Create a project to save schema, ER diagrams, and queries offline.
-
-
-
-
-
-
- );
-}
diff --git a/src/features/project/components/DeleteProjectDialog.tsx b/src/features/project/components/DeleteProjectDialog.tsx
deleted file mode 100644
index 095f055..0000000
--- a/src/features/project/components/DeleteProjectDialog.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
-
-interface DeleteProjectDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- projectName: string | undefined;
- onConfirm: () => void;
-}
-
-export function DeleteProjectDialog({
- open,
- onOpenChange,
- projectName,
- onConfirm,
-}: DeleteProjectDialogProps) {
- return (
-
-
-
- Delete Project
-
- Are you sure you want to delete "{projectName}"? This will remove
- all saved schema caches, ER diagram layouts, and saved queries. This
- action cannot be undone.
-
-
-
- Cancel
-
- Delete
-
-
-
-
- );
-}
diff --git a/src/features/project/components/MigrationSyncDialog.tsx b/src/features/project/components/MigrationSyncDialog.tsx
new file mode 100644
index 0000000..0340e01
--- /dev/null
+++ b/src/features/project/components/MigrationSyncDialog.tsx
@@ -0,0 +1,290 @@
+import { useEffect, useState } from "react";
+import { ImportAnalysis } from "../types";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Badge } from "@/components/ui/badge";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { AlertTriangle, ShieldAlert, FileJson, Loader2, Database, ShieldX, Play } from "lucide-react";
+import { projectService } from "@/services/bridge/project";
+import { toast } from "sonner";
+import { cn } from "@/lib/utils";
+import SQLPreviewSheet from "@/components/ui/SQLPreviewSheet";
+
+interface MigrationSyncDialogProps {
+ projectId: string;
+ analysis: ImportAnalysis | null;
+ onClose: () => void;
+ onApplied: () => void;
+}
+
+export function MigrationSyncDialog({
+ projectId,
+ analysis,
+ onClose,
+ onApplied,
+}: MigrationSyncDialogProps) {
+ const [open, setOpen] = useState(false);
+ const [isApplying, setIsApplying] = useState(false);
+ const [previewSql, setPreviewSql] = useState(null);
+
+ useEffect(() => {
+ if (analysis) {
+ if (analysis.driftStatus === "synced") {
+ toast.success("Project is synced", {
+ description: "Schema matches exactly — nothing to apply.",
+ duration: 3000,
+ });
+ onClose();
+ return;
+ }
+ setOpen(true);
+ } else {
+ setOpen(false);
+ }
+ }, [analysis, onClose]);
+
+ const handleApplyMigrations = async () => {
+ try {
+ setIsApplying(true);
+ await projectService.applyMigrations(projectId);
+ toast.success("Migrations applied successfully");
+ onApplied();
+ setOpen(false);
+ } catch (error: any) {
+ toast.error("Failed to apply migrations", {
+ description: error.message,
+ });
+ } finally {
+ setIsApplying(false);
+ }
+ };
+
+ const handleApplySnapshot = async () => {
+ try {
+ setIsApplying(true);
+ await projectService.applySnapshot(projectId);
+ toast.success("Snapshot applied successfully");
+ onApplied();
+ setOpen(false);
+ } catch (error: any) {
+ toast.error("Failed to apply snapshot", {
+ description: error.message,
+ });
+ } finally {
+ setIsApplying(false);
+ }
+ };
+
+ const handlePreviewSQL = async () => {
+ try {
+ const sql = await projectService.generateSQL(projectId);
+ setPreviewSql(sql);
+ } catch (error: any) {
+ toast.error("Failed to generate SQL", {
+ description: error.message,
+ });
+ }
+ };
+
+ if (!analysis) return null;
+
+ // STATE 4: No migrations, no schema.json
+ if (!analysis.hasMigrations && !analysis.hasSchemaSnapshot) {
+ return (
+ { }}>
+ e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}>
+
+
+
+ Incomplete Project
+
+
+ We could not find any migration files or a schema snapshot in this repository.
+ RelWave cannot reconstruct the database from an empty state.
+
+
+
+ { setOpen(false); onClose(); }}>
+ Open Anyway
+
+
+
+
+ );
+ }
+
+ // STATE 2: Migrations exist, schema differs
+ if (analysis.hasMigrations && analysis.driftStatus !== "synced") {
+ const hasDestructive = analysis.pendingMigrations.some(m => m.isDestructive);
+ const tampered = analysis.lockFileStatus === "tampered";
+
+ return (
+ { if (!val) { setOpen(false); onClose(); } }}>
+
+
+
+
+ Pending Migrations
+
+
+ This project has new migrations that have not been applied to the live database.
+
+
+
+
+
+
+ {analysis.pendingMigrations.map((m) => (
+
+
+ {m.file}
+ {m.isDestructive && (
+ DESTRUCTIVE
+ )}
+
+ {m.isDestructive && m.destructiveOps.length > 0 && (
+
+
+ {m.destructiveOps.map((op, i) => {op} )}
+
+
+ )}
+
+ ))}
+
+
+
+ {hasDestructive && (
+
+
+ Warning: Destructive Operations
+
+ 1 or more migrations contain destructive operations that could lead to data loss.
+
+
+ )}
+
+ {!analysis.targetDatabaseEmpty && (
+
+
+ Target database is not empty
+
+ Applying migrations to a non-empty database may cause conflicts.
+
+
+ )}
+
+ {tampered && (
+
+
+ Lock File Mismatch
+
+ The following migration files may have been modified after being applied:
+
+ {analysis.tamperedFiles.map(f => {f} )}
+
+ Proceeding is not recommended.
+
+
+ )}
+
+
+
+ {isApplying ? (
+
+
+ Applying migrations...
+
+ ) : (
+ <>
+ { setOpen(false); onClose(); }}>
+ Skip for now
+
+
+
+ Apply Migrations
+
+ >
+ )}
+
+
+
+ );
+ }
+
+ // STATE 3: No migrations, schema.json exists
+ if (!analysis.hasMigrations && analysis.hasSchemaSnapshot && analysis.driftStatus !== "synced") {
+ return (
+ { if (!val) { setOpen(false); onClose(); } }}>
+
+
+
+
+ Apply Schema Snapshot
+
+
+ No migration history found, but a schema snapshot is available.
+ We can generate a baseline migration to reconstruct the schema.
+
+
+
+
+ {!analysis.targetDatabaseEmpty && (
+
+
+ Target database is not empty
+
+ Applying baseline to a non-empty database may cause conflicts.
+
+
+ )}
+
+
+
+ {isApplying ? (
+
+
+ Applying snapshot...
+
+ ) : (
+ <>
+
+ { setOpen(false); onClose(); }}>
+ Skip
+
+
+
+ Preview SQL
+
+
+ Generate & Apply
+
+ >
+ )}
+
+
+
+ { if (!val) setPreviewSql(null); }}
+ sql={previewSql || ""}
+ title="Baseline SQL Preview"
+ />
+
+ );
+ }
+
+ return null;
+}
diff --git a/src/features/project/components/ProjectDetailView.tsx b/src/features/project/components/ProjectDetailView.tsx
deleted file mode 100644
index 9ae5188..0000000
--- a/src/features/project/components/ProjectDetailView.tsx
+++ /dev/null
@@ -1,206 +0,0 @@
-import {
- Clock,
- Layers,
- GitBranch,
- FileCode2,
- ExternalLink,
- MoreHorizontal,
- Trash2,
- Download,
- FolderOpen,
- ArrowLeft,
-} from "lucide-react";
-import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { cn } from "@/lib/utils";
-import { ProjectSummary } from "@/features/project/types";
-import {
- Card,
- CardAction,
- CardContent,
- CardFooter,
- CardHeader,
- CardTitle
-} from "@/components/ui/card";
-
-interface ProjectDetailViewProps {
- project: ProjectSummary;
- schemaCount?: number;
- queryCount?: number;
- hasERLayout?: boolean;
- onOpen: () => void;
- onDelete: () => void;
- onExport: () => void;
- onBack?: () => void;
-}
-
-function formatRelativeTime(dateStr: string) {
- const diff = Date.now() - new Date(dateStr).getTime();
- const minutes = Math.floor(diff / 60000);
- if (minutes < 1) return "just now";
- if (minutes < 60) return `${minutes}m ago`;
- const hours = Math.floor(minutes / 60);
- if (hours < 24) return `${hours}h ago`;
- const days = Math.floor(hours / 24);
- if (days < 30) return `${days}d ago`;
- return new Date(dateStr).toLocaleDateString();
-}
-
-const ENGINE_COLORS: Record = {
- postgres: { bg: "bg-blue-500/10", text: "text-blue-500" },
- postgresql: { bg: "bg-blue-500/10", text: "text-blue-500" },
- mysql: { bg: "bg-orange-500/10", text: "text-orange-500" },
- mariadb: { bg: "bg-sky-500/10", text: "text-sky-500" },
- sqlite: { bg: "bg-cyan-500/10", text: "text-cyan-500" },
-};
-
-export function ProjectDetailView({
- project,
- schemaCount,
- queryCount,
- hasERLayout,
- onOpen,
- onDelete,
- onExport,
- onBack,
-}: ProjectDetailViewProps) {
- const colors =
- ENGINE_COLORS[project.engine?.toLowerCase() ?? ""] ?? {
- bg: "bg-primary/10",
- text: "text-primary",
- };
-
- return (
-
- {/* Header */}
-
-
-
-
-
-
-
-
-
-
{project.name}
- {project.engine && (
-
- {project.engine}
-
- )}
-
- {project.description && (
-
- {project.description}
-
- )}
-
-
-
- Updated {formatRelativeTime(project.updatedAt)}
-
-
-
-
-
- {/* Actions */}
-
-
-
- Open
-
-
-
-
-
-
-
-
-
-
- Export Bundle
-
-
-
- Delete Project
-
-
-
-
-
-
-
- {/* Content cards */}
-
-
Project Data
-
- {/* Schema card */}
-
-
-
-
-
- Schema Cache
-
-
- {schemaCount ?? "—"}
-
-
- Cached schemas
-
-
-
- {/* ER Diagram card */}
-
-
-
-
-
- ER Diagram
-
-
- {hasERLayout ? "Saved" : "—"}
-
-
- Diagram layout
-
-
-
- {/* Queries card */}
-
-
-
-
-
- Saved Queries
-
-
- {queryCount ?? "—"}
-
-
- Stored queries
-
-
-
-
-
- );
-}
diff --git a/src/features/project/components/ProjectList.tsx b/src/features/project/components/ProjectList.tsx
deleted file mode 100644
index 68037d7..0000000
--- a/src/features/project/components/ProjectList.tsx
+++ /dev/null
@@ -1,183 +0,0 @@
-import {
- Plus,
- FolderOpen,
- Search,
- Trash2,
- Database,
- Upload,
-} from "lucide-react";
-import { Input } from "@/components/ui/input";
-import { Button } from "@/components/ui/button";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import { cn } from "@/lib/utils";
-import { ProjectSummary } from "@/features/project/types";
-import { Empty, EmptyContent, EmptyDescription } from "@/components/ui/empty";
-import { Spinner } from "@/components/ui/spinner";
-
-interface ProjectListProps {
- projects: ProjectSummary[];
- filteredProjects: ProjectSummary[];
- loading: boolean;
- searchQuery: string;
- setSearchQuery: (q: string) => void;
- selectedProject: string | null;
- setSelectedProject: (id: string | null) => void;
- onCreateClick: () => void;
- onImportClick: () => void;
- onDelete: (id: string, name: string) => void;
- onOpen: (id: string) => void;
-}
-
-const ENGINE_COLORS: Record = {
- postgres: "text-blue-500",
- postgresql: "text-blue-500",
- mysql: "text-orange-500",
- mariadb: "text-sky-500",
- sqlite: "text-cyan-500",
-};
-
-export function ProjectList({
- projects,
- filteredProjects,
- loading,
- searchQuery,
- setSearchQuery,
- selectedProject,
- setSelectedProject,
- onCreateClick,
- onImportClick,
- onDelete,
- onOpen,
-}: ProjectListProps) {
- return (
-
- {/* Header */}
-
-
-
-
- setSearchQuery(e.target.value)}
- className="h-8 pl-8 text-xs bg-background/50"
- />
-
-
-
- {/* List */}
-
- {loading ? (
-
-
-
- ) : filteredProjects.length === 0 ? (
-
-
-
- {projects.length === 0
- ? "No projects yet"
- : "No matches found"}
-
- {projects.length === 0 && (
-
-
- Create your first project
-
-
- )}
-
- ) : (
-
- {filteredProjects.map((project) => {
- const isSelected = selectedProject === project.id;
- const engineColor =
- ENGINE_COLORS[project.engine?.toLowerCase() ?? ""] ??
- "text-muted-foreground";
-
- return (
-
-
- setSelectedProject(project.id)}
- onDoubleClick={() => onOpen(project.id)}
- className={cn(
- "w-full h-auto justify-start flex items-center gap-3.5 px-2.5 py-2 rounded-md text-left transition-colors",
- isSelected
- ? "bg-accent text-accent-foreground"
- : "hover:bg-accent/50 text-foreground"
- )}
- >
-
-
-
- {project.name}
-
-
- {project.engine ?? "—"}{" "}
- {project.description
- ? `• ${project.description}`
- : ""}
-
-
-
-
-
- onOpen(project.id)}>
-
- Open Project
-
-
- onDelete(project.id, project.name)}
- >
-
- Delete
-
-
-
- );
- })}
-
- )}
-
-
- );
-}
diff --git a/src/features/project/components/ProjectsEmptyState.tsx b/src/features/project/components/ProjectsEmptyState.tsx
deleted file mode 100644
index 04f566b..0000000
--- a/src/features/project/components/ProjectsEmptyState.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import { FolderOpen, Sparkles, Upload } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import {
- Empty,
- EmptyContent,
- EmptyDescription,
- EmptyHeader,
- EmptyMedia,
- EmptyTitle
-} from "@/components/ui/empty";
-
-interface ProjectsEmptyStateProps {
- hasProjects: boolean;
- onCreateClick: () => void;
- onImportClick: () => void;
-}
-
-export function ProjectsEmptyState({ hasProjects, onCreateClick, onImportClick }: ProjectsEmptyStateProps) {
- if (!hasProjects) {
- return (
-
-
-
-
-
- No Projects Yet
-
- You haven't created any projects yet. Get started by creating
- your first project.
-
-
-
- {!hasProjects && (
-
-
-
- Create Your First Project
-
-
-
- Import Project
-
-
- )}
-
- )
- }
- return (
-
-
-
-
-
-
-
- Projects
-
-
- Save database details, ER diagrams & queries offline
-
-
-
-
- )
-}
diff --git a/src/features/project/components/SchemaDriftSheet.tsx b/src/features/project/components/SchemaDriftSheet.tsx
new file mode 100644
index 0000000..d591aa3
--- /dev/null
+++ b/src/features/project/components/SchemaDriftSheet.tsx
@@ -0,0 +1,162 @@
+import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
+import { SchemaDiff } from "../types";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { ChevronDown, RefreshCw } from "lucide-react";
+import { useState } from "react";
+import { projectService } from "@/services/bridge/project";
+import { toast } from "sonner";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { cn } from "@/lib/utils";
+
+interface SchemaDriftSheetProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ projectId: string;
+ connectionName: string;
+ capturedDate?: string;
+ driftDetails?: SchemaDiff;
+ onRefresh: () => void;
+}
+
+export function SchemaDriftSheet({
+ open,
+ onOpenChange,
+ projectId,
+ connectionName,
+ capturedDate,
+ driftDetails,
+ onRefresh,
+}: SchemaDriftSheetProps) {
+ const [isRefreshing, setIsRefreshing] = useState(false);
+
+ const handleRefresh = async () => {
+ try {
+ setIsRefreshing(true);
+ await projectService.refreshSchemaCache(projectId);
+ toast.success("Schema cache refreshed successfully");
+ onRefresh();
+ onOpenChange(false);
+ } catch (error: any) {
+ toast.error("Failed to refresh cache", { description: error.message });
+ } finally {
+ setIsRefreshing(false);
+ }
+ };
+
+ const hasAdded = (driftDetails?.tablesAdded?.length ?? 0) > 0;
+ const hasRemoved = (driftDetails?.tablesRemoved?.length ?? 0) > 0;
+ const hasModified = (driftDetails?.tablesModified?.length ?? 0) > 0;
+ const hasDriftData = hasAdded || hasRemoved || hasModified;
+
+ return (
+
+
+
+ Schema Drift — {connectionName}
+
+ Live database differs from schema.json
+ {capturedDate ? ` captured ${new Date(capturedDate).toLocaleString()}` : ""}.
+
+
+
+ {!hasDriftData ? (
+
+
🔍
+
No detailed drift data available
+
+ Structural schema diffing is not yet implemented. Drift was detected from pending
+ migrations, not a live schema comparison. This is tracked for a future release.
+
+
+ ) : (
+
+
+ {hasAdded && (
+
+
+
+ Tables Added
+
+ {driftDetails!.tablesAdded.length}
+
+
+
+
+
+
+ {driftDetails!.tablesAdded.map((t) => {t} )}
+
+
+
+ )}
+
+ {hasRemoved && (
+
+
+
+ Tables Removed
+
+ {driftDetails!.tablesRemoved.length}
+
+
+
+
+
+
+ {driftDetails!.tablesRemoved.map((t) => {t} )}
+
+
+
+ )}
+
+ {hasModified && (
+
+
+
+ Tables Modified
+
+ {driftDetails!.tablesModified.length}
+
+
+
+
+
+
+ {driftDetails!.tablesModified.map((m) => (
+
+
{m.tableName}
+ {m.columnsAdded.length > 0 && (
+
+ {m.columnsAdded.join(", ")}
+ )}
+ {m.columnsRemoved.length > 0 && (
+
- {m.columnsRemoved.join(", ")}
+ )}
+ {m.columnsChanged.length > 0 && (
+
~ {m.columnsChanged.join(", ")}
+ )}
+ {m.constraintsChanged.length > 0 && (
+
~ {m.constraintsChanged.join(", ")}
+ )}
+
+ ))}
+
+
+
+ )}
+
+
+ )}
+
+
+ onOpenChange(false)}>Close
+
+
+ Refresh Cache
+
+
+
+
+ );
+}
diff --git a/src/features/project/components/UnlinkedProjectItem.tsx b/src/features/project/components/UnlinkedProjectItem.tsx
new file mode 100644
index 0000000..551decc
--- /dev/null
+++ b/src/features/project/components/UnlinkedProjectItem.tsx
@@ -0,0 +1,91 @@
+import React from "react";
+import { AlertTriangle, Folder, Link2, Trash2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { useDatabases } from "@/features/project/hooks/useDbQueries";
+import { toast } from "sonner";
+import { useDeleteProject, useRelinkProject } from "@/features/project/hooks/useProjectQueries";
+
+export function UnlinkedProjectItem({ project }: { project: any }) {
+ const { data: databases = [] } = useDatabases();
+ const deleteProjectMutation = useDeleteProject();
+ const relinkProjectMutation = useRelinkProject();
+
+ const handleRelink = async (databaseId: string) => {
+ try {
+ await relinkProjectMutation.mutateAsync({ projectId: project.id, databaseId });
+ toast.success("Project relinked successfully");
+ } catch (error: any) {
+ toast.error("Failed to relink project", { description: error.message });
+ }
+ };
+
+ const handleDelete = async () => {
+ try {
+ await deleteProjectMutation.mutateAsync(project.id);
+ toast.success("Project deleted");
+ } catch (error: any) {
+ toast.error("Failed to delete project", { description: error.message });
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
No connection linked
+
+
+
+
+
+
+
+
+
+ Relink
+
+
+
+ {databases.length === 0 ? (
+ No databases available
+ ) : (
+ databases.map(db => (
+ handleRelink(db.id)}>
+
+
+ ))
+ )}
+
+
+
+
+
+ Delete
+
+
+
+ );
+}
diff --git a/src/features/project/components/index.ts b/src/features/project/components/index.ts
index afb538e..117e5b1 100644
--- a/src/features/project/components/index.ts
+++ b/src/features/project/components/index.ts
@@ -1,5 +1 @@
-export { ProjectList } from "./ProjectList";
-export { CreateProjectDialog } from "./CreateProjectDialog";
-export { DeleteProjectDialog } from "./DeleteProjectDialog";
export { ImportProjectDialog } from "./ImportProjectDialog";
-export { ProjectDetailView } from "./ProjectDetailView";
diff --git a/src/features/project/hooks/useImportAnalysis.ts b/src/features/project/hooks/useImportAnalysis.ts
new file mode 100644
index 0000000..c423770
--- /dev/null
+++ b/src/features/project/hooks/useImportAnalysis.ts
@@ -0,0 +1,37 @@
+import { useState, useEffect, useCallback } from "react";
+import { projectService } from "@/services/bridge/project";
+import { ImportAnalysis } from "../types";
+
+export function useImportAnalysis(projectId?: string, targetDatabaseId?: string) {
+ const [analysis, setAnalysis] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const fetchAnalysis = useCallback(async () => {
+ if (!projectId) return;
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await projectService.analyzeImport(projectId);
+ setAnalysis(data);
+ } catch (err: any) {
+ console.error("Failed to fetch import analysis:", err);
+ setError(err instanceof Error ? err : new Error(err?.message || "Unknown error"));
+ } finally {
+ setLoading(false);
+ }
+ }, [projectId]);
+
+ useEffect(() => {
+ if (projectId && targetDatabaseId) {
+ fetchAnalysis();
+ }
+ }, [projectId, targetDatabaseId, fetchAnalysis]);
+
+ return {
+ analysis,
+ loading,
+ error,
+ refetch: fetchAnalysis
+ };
+}
diff --git a/src/features/project/hooks/useProjectQueries.ts b/src/features/project/hooks/useProjectQueries.ts
index b3fa466..2e73ba1 100644
--- a/src/features/project/hooks/useProjectQueries.ts
+++ b/src/features/project/hooks/useProjectQueries.ts
@@ -148,6 +148,29 @@ export function useDeleteProject() {
});
}
+export function useDeleteProjectWithConnection() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (projectId: string) => projectService.deleteWithConnection(projectId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: projectKeys.all });
+ },
+ });
+}
+
+export function useRelinkProject() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ projectId, databaseId }: { projectId: string; databaseId: string }) =>
+ projectService.relinkToConnection(projectId, databaseId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: projectKeys.all });
+ },
+ });
+}
+
// ============================================
// Project Schema (cached offline data)
// ============================================
diff --git a/src/features/project/hooks/useProjectSync.ts b/src/features/project/hooks/useProjectSync.ts
index 6a629b2..8dbcc2c 100644
--- a/src/features/project/hooks/useProjectSync.ts
+++ b/src/features/project/hooks/useProjectSync.ts
@@ -1,8 +1,9 @@
-import { useEffect, useRef, useCallback } from "react";
+import { useEffect, useRef, useCallback, useState } from "react";
import { useProjectByDatabaseId } from "@/features/project/hooks/useProjectQueries";
import { schemaGroupsToSnapshots } from "@/lib/schemaConverters";
import type { DatabaseSchemaDetails } from "@/features/database/types";
import type { ERNode } from "@/features/project/types";
+import type { ImportAnalysis } from "@/features/project/types";
import { projectService } from "@/services/bridge/project";
// ==========================================
@@ -23,6 +24,12 @@ interface UseProjectSyncReturn {
isLoading: boolean;
/** Save ER diagram node positions (debounced externally by caller) */
saveERDiagram: (nodes: ERNode[], zoom?: number, panX?: number, panY?: number) => void;
+ /** Import analysis result — null while loading or if no project */
+ importAnalysis: ImportAnalysis | null;
+ /** Whether the import analysis is still loading */
+ importAnalysisLoading: boolean;
+ /** Re-fetch import analysis (e.g. after applying migrations) */
+ refetchImportAnalysis: () => void;
}
export function useProjectSync(
@@ -35,12 +42,54 @@ export function useProjectSync(
// Track what we last synced to avoid redundant writes
const lastSyncedSchemaRef = useRef(null);
+ // -----------------------------------------
+ // Import analysis — check if project has pending migrations
+ // -----------------------------------------
+ const [importAnalysis, setImportAnalysis] = useState(null);
+ const [importAnalysisLoading, setImportAnalysisLoading] = useState(false);
+ const analysisCheckedRef = useRef(null);
+
+ const fetchImportAnalysis = useCallback(async () => {
+ if (!projectId) return;
+ setImportAnalysisLoading(true);
+ try {
+ const data = await projectService.analyzeImport(projectId);
+ setImportAnalysis(data);
+ } catch (err: any) {
+ console.warn("[ProjectSync] Import analysis failed:", err.message);
+ // On failure, allow schema sync to proceed (fail-open)
+ setImportAnalysis(null);
+ } finally {
+ setImportAnalysisLoading(false);
+ }
+ }, [projectId]);
+
+ useEffect(() => {
+ if (projectId && projectId !== analysisCheckedRef.current) {
+ analysisCheckedRef.current = projectId;
+ fetchImportAnalysis();
+ }
+ }, [projectId, fetchImportAnalysis]);
+
// -----------------------------------------
// Auto-sync schema when fresh data arrives
// -----------------------------------------
useEffect(() => {
if (!projectId || !schemaData?.schemas?.length) return;
+ // Don't overwrite the imported schema if there are pending migrations
+ // or a schema snapshot waiting to be applied to an empty database.
+ // Only auto-sync when analysis confirms the project is "synced"
+ // (i.e., the live DB matches the project state).
+ if (importAnalysisLoading) return; // wait until analysis finishes
+ if (importAnalysis && importAnalysis.driftStatus !== "synced") {
+ console.debug(
+ "[ProjectSync] Skipping schema auto-save — project has pending drift:",
+ importAnalysis.driftStatus
+ );
+ return;
+ }
+
// Build a lightweight fingerprint to avoid re-saving identical data.
// We use schema/table count as a quick check (cheap to compute).
const fingerprint = schemaData.schemas
@@ -61,7 +110,7 @@ export function useProjectSync(
.catch((err) => {
console.warn("[ProjectSync] Schema sync failed:", err.message);
});
- }, [projectId, schemaData]);
+ }, [projectId, schemaData, importAnalysis, importAnalysisLoading]);
// -----------------------------------------
// ER Diagram save helper
@@ -81,5 +130,5 @@ export function useProjectSync(
[projectId]
);
- return { projectId, isLoading, saveERDiagram };
+ return { projectId, isLoading, saveERDiagram, importAnalysis, importAnalysisLoading, refetchImportAnalysis: fetchImportAnalysis };
}
diff --git a/src/features/project/types.ts b/src/features/project/types.ts
index 7e1cf8e..02efbb4 100644
--- a/src/features/project/types.ts
+++ b/src/features/project/types.ts
@@ -144,3 +144,50 @@ export interface ImportProjectParams {
sourcePath: string;
databaseId: string;
}
+
+// ==========================================
+// Sync and Migration Types
+// ==========================================
+
+export interface SchemaDiff {
+ tablesAdded: string[];
+ tablesRemoved: string[];
+ tablesModified: Array<{
+ tableName: string;
+ columnsAdded: string[];
+ columnsRemoved: string[];
+ columnsChanged: string[];
+ constraintsChanged: string[];
+ }>;
+}
+
+export interface ImportAnalysis {
+ hasMigrations: boolean;
+ migrationCount: number;
+ hasSchemaSnapshot: boolean;
+ lockFileStatus: "valid" | "tampered" | "missing";
+ tamperedFiles: string[];
+
+ targetDatabaseEmpty: boolean;
+ targetTableCount: number;
+
+ driftStatus: "synced" | "drifted" | "unknown";
+ driftDetails?: SchemaDiff;
+
+ pendingMigrations: Array<{
+ file: string;
+ version: string;
+ isDestructive: boolean;
+ destructiveOps: string[];
+ }>;
+
+ availableModes: Array<"run_migrations" | "apply_snapshot" | "skip">;
+ recommendedMode: "run_migrations" | "apply_snapshot" | "skip";
+}
+
+export interface MigrationLockVerifyResult {
+ valid: boolean;
+ tamperedFiles: string[];
+ missingFiles: string[];
+ unknownFiles: string[];
+}
diff --git a/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx b/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx
new file mode 100644
index 0000000..acb8289
--- /dev/null
+++ b/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx
@@ -0,0 +1,127 @@
+import { useState } from "react";
+import { Bot, Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { AIResultDialog } from "@/features/ai/components/AIResultDialog";
+import { useAISettings } from "@/features/ai/hooks/useAISettings";
+import { aiService, type SchemaAnalysisInput } from "@/services/bridge/ai";
+import { DatabaseSchemaDetails } from "@/features/database/types";
+
+interface AnalyzeSchemaButtonProps {
+ schemaData: DatabaseSchemaDetails & {
+ schemas: Array<{
+ name?: string;
+ tables: Array<{
+ name: string;
+ columns: Array<{
+ name: string;
+ type: string;
+ nullable?: boolean;
+ isPrimaryKey?: boolean;
+ isForeignKey?: boolean;
+ }>;
+ }>;
+ }>;
+ };
+ databaseType?: string;
+}
+
+export function AnalyzeSchemaButton({ schemaData, databaseType }: AnalyzeSchemaButtonProps) {
+ const settings = useAISettings();
+ const [open, setOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [markdown, setMarkdown] = useState();
+ const [error, setError] = useState(null);
+ const [cached, setCached] = useState();
+ const [createdAt, setCreatedAt] = useState();
+
+ const tableCount = schemaData.schemas?.flatMap((s) => s.tables).length ?? 0;
+
+ const buildInput = (): SchemaAnalysisInput => ({
+ databaseType,
+ tables: schemaData.schemas.flatMap((schema) =>
+ schema.tables.map((table) => ({
+ name: table.name,
+ schema: schema.name,
+ columns: table.columns.map((col) => ({
+ name: col.name,
+ type: col.type,
+ nullable: col.nullable,
+ isPrimaryKey: col.isPrimaryKey,
+ isForeignKey: col.isForeignKey,
+ })),
+ }))
+ ),
+ });
+
+ const handleAnalyze = async (skipCache = false) => {
+ setOpen(true);
+ if (markdown && !skipCache) return; // Already analyzed — reuse result
+ setLoading(true);
+ setError(null);
+
+ try {
+ const input = buildInput();
+ const result = await aiService.analyzeSchema(settings, input, {
+ skipCache,
+ datasourceName: schemaData.name,
+ });
+ setMarkdown(result.markdown);
+ setCached(result.cached);
+ setCreatedAt(result.createdAt);
+ } catch (err: any) {
+ setError(err?.message ?? String(err));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleReanalyze = () => {
+ setMarkdown(undefined);
+ setCached(undefined);
+ setCreatedAt(undefined);
+ handleAnalyze(true);
+ };
+
+ // Reset cached result when dialog closes so next open re-fetches
+ const handleOpenChange = (next: boolean) => {
+ setOpen(next);
+ if (!next) {
+ setMarkdown(undefined);
+ setError(null);
+ setCached(undefined);
+ setCreatedAt(undefined);
+ }
+ };
+
+ return (
+ <>
+ handleAnalyze()}
+ disabled={tableCount === 0}
+ >
+ {loading ? (
+
+ ) : (
+
+ )}
+ Analyze Schema
+
+
+
+ >
+ );
+}
diff --git a/src/features/schema-explorer/components/SchemaExplorerHeader.tsx b/src/features/schema-explorer/components/SchemaExplorerHeader.tsx
index 19f72ff..84f884d 100644
--- a/src/features/schema-explorer/components/SchemaExplorerHeader.tsx
+++ b/src/features/schema-explorer/components/SchemaExplorerHeader.tsx
@@ -6,11 +6,19 @@ import CreateTableDialog from './CreateTableDialog'
import AddIndexesDialog from './AddIndexesDialog'
import DropTableDialog from './DropTableDialog'
import AlterTableDialog from './AlterTableDialog'
+import { AnalyzeSchemaButton } from './AnalyzeSchemaButton'
interface SchemaExplorerHeaderProps {
dbId: string;
database: {
name: string;
+ schemas?: Array<{
+ name?: string;
+ tables: Array<{
+ name: string;
+ columns: Array<{ name: string; type: string; nullable?: boolean; isPrimaryKey?: boolean; isForeignKey?: boolean }>;
+ }>;
+ }>;
};
onTableCreated?: () => void;
selectedTable?: { schema: string; name: string; columns: string[] } | null;
@@ -23,7 +31,7 @@ const SchemaExplorerHeader = ({ dbId, database, onTableCreated, selectedTable }:
const [dropTableOpen, setDropTableOpen] = useState(false);
const [alterTableOpen, setAlterTableOpen] = useState(false);
- const defaultSchema = 'public';
+ const defaultSchema = selectedTable?.schema || database.schemas?.[0]?.name || 'public';
return (
@@ -39,6 +47,13 @@ const SchemaExplorerHeader = ({ dbId, database, onTableCreated, selectedTable }:
{/* Action Buttons */}
+ {/* AI Analyze Schema */}
+ {database.schemas && database.schemas.length > 0 && (
+
+ )}
+
{/* Table Actions - only show if table is selected */}
{selectedTable && (
<>
diff --git a/src/features/settings/components/AISettings.tsx b/src/features/settings/components/AISettings.tsx
new file mode 100644
index 0000000..db3b460
--- /dev/null
+++ b/src/features/settings/components/AISettings.tsx
@@ -0,0 +1,335 @@
+import { useState, useEffect } from "react";
+import { Bot, Eye, EyeOff, CheckCircle2, XCircle, Loader2, ChevronDown } from "lucide-react";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { cn } from "@/lib/utils";
+import {
+ AIProviderName,
+ type AISettings as AISettingsData,
+ loadAISettings,
+ saveAISettings,
+ aiService,
+} from "@/services/bridge/ai";
+
+// ── Provider metadata ─────────────────────────────────────────────────────
+
+interface ProviderMeta {
+ name: AIProviderName;
+ label: string;
+ description: string;
+ requiresKey: boolean;
+ keyField?: keyof AISettingsData;
+ keyPlaceholder?: string;
+ extraFields?: Array<{
+ field: keyof AISettingsData;
+ label: string;
+ placeholder: string;
+ type?: string;
+ }>;
+}
+
+const PROVIDERS: ProviderMeta[] = [
+ {
+ name: "anthropic",
+ label: "Claude (Anthropic)",
+ description: "claude-3-5-haiku-20241022",
+ requiresKey: true,
+ keyField: "anthropicApiKey",
+ keyPlaceholder: "sk-ant-api03-…",
+ },
+ {
+ name: "openai",
+ label: "OpenAI",
+ description: "gpt-4o-mini",
+ requiresKey: true,
+ keyField: "openaiApiKey",
+ keyPlaceholder: "sk-proj-…",
+ },
+ {
+ name: "gemini",
+ label: "Gemini (Google)",
+ description: "gemini-1.5-flash",
+ requiresKey: true,
+ keyField: "geminiApiKey",
+ keyPlaceholder: "AIzaSy…",
+ },
+ {
+ name: "groq",
+ label: "Groq",
+ description: "llama-3.3-70b-versatile",
+ requiresKey: true,
+ keyField: "groqApiKey",
+ keyPlaceholder: "gsk_…",
+ },
+ {
+ name: "mistral",
+ label: "Mistral",
+ description: "mistral-small-latest",
+ requiresKey: true,
+ keyField: "mistralApiKey",
+ keyPlaceholder: "…",
+ },
+ {
+ name: "ollama",
+ label: "Ollama (Local)",
+ description: "Runs entirely on your machine",
+ requiresKey: false,
+ extraFields: [
+ {
+ field: "ollamaBaseUrl",
+ label: "Base URL",
+ placeholder: "http://localhost:11434",
+ },
+ {
+ field: "ollamaModel",
+ label: "Model",
+ placeholder: "llama3.2",
+ },
+ ],
+ },
+];
+
+// ── Connection status indicator ───────────────────────────────────────────
+
+type ConnectionStatus = "idle" | "testing" | "ok" | "error";
+
+function StatusBadge({ status, message }: { status: ConnectionStatus; message?: string }) {
+ if (status === "idle") return null;
+ return (
+
+ {status === "testing" && }
+ {status === "ok" && }
+ {status === "error" && }
+ {status === "testing" ? "Testing…" : status === "ok" ? "Connected" : (message ?? "Failed")}
+
+ );
+}
+
+// ── Password field with show/hide ─────────────────────────────────────────
+
+function SecretInput({
+ value,
+ onChange,
+ placeholder,
+ id,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ placeholder?: string;
+ id: string;
+}) {
+ const [show, setShow] = useState(false);
+ return (
+
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ className="pr-9 h-8 text-xs font-mono border-border/40"
+ autoComplete="off"
+ spellCheck={false}
+ />
+ setShow((s) => !s)}
+ tabIndex={-1}
+ aria-label={show ? "Hide" : "Show"}
+ >
+ {show ? : }
+
+
+ );
+}
+
+// ── Main component ────────────────────────────────────────────────────────
+
+export default function AISettings() {
+ const [settings, setSettings] = useState
(loadAISettings);
+ const [dirty, setDirty] = useState(false);
+ const [status, setStatus] = useState("idle");
+ const [statusMessage, setStatusMessage] = useState();
+
+ // Load from storage on mount
+ useEffect(() => {
+ setSettings(loadAISettings());
+ }, []);
+
+ const activeProvider = PROVIDERS.find((p) => p.name === settings.defaultProvider) ?? PROVIDERS[0];
+
+ const update = (patch: Partial) => {
+ setSettings((prev) => ({ ...prev, ...patch }));
+ setDirty(true);
+ setStatus("idle");
+ };
+
+ const handleSave = () => {
+ saveAISettings(settings);
+ setDirty(false);
+ setStatus("idle");
+ };
+
+ const handleTest = async () => {
+ // Save first so the bridge gets the latest values
+ saveAISettings(settings);
+ setDirty(false);
+ setStatus("testing");
+ setStatusMessage(undefined);
+ const result = await aiService.testConnection(settings);
+ if (result.connected) {
+ setStatus("ok");
+ } else {
+ setStatus("error");
+ setStatusMessage(result.message);
+ }
+ };
+
+ return (
+
+ {/* Section header */}
+
+
+
+
+
+
AI Settings
+
+ Configure your AI provider. Keys stay on your machine.
+
+
+
+
+
+ {/* Provider selector */}
+
+
+
Active Provider
+
{activeProvider.description}
+
+
+
+
+ {activeProvider.label}
+
+
+
+
+ {PROVIDERS.map((p) => (
+ update({ defaultProvider: p.name })}
+ >
+ {p.label}
+ {p.description}
+
+ ))}
+
+
+
+
+ {/* Credential fields for the active provider */}
+
+ {activeProvider.requiresKey && activeProvider.keyField && (
+
+
+ API Key
+
+ update({ [activeProvider.keyField!]: v })}
+ placeholder={activeProvider.keyPlaceholder}
+ />
+
+ )}
+
+ {activeProvider.extraFields?.map((field) => (
+
+
+ {field.label}
+
+ update({ [field.field]: e.target.value })}
+ placeholder={field.placeholder}
+ className="h-8 text-xs border-border/40"
+ spellCheck={false}
+ />
+
+ ))}
+
+
+ {/* Actions */}
+
+
+
+
+ {status === "testing" ? (
+
+ ) : null}
+ Test Connection
+
+
+ Save
+
+
+
+
+
+ {/* All providers key summary (collapsed) */}
+
+
+
+ Configure other providers
+
+
+ {PROVIDERS.filter((p) => p.name !== settings.defaultProvider && p.requiresKey).map((provider) => (
+
+
+ {provider.label}
+ {provider.description}
+
+ update({ [provider.keyField!]: v })}
+ placeholder={provider.keyPlaceholder}
+ />
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/features/settings/components/CheckForUpdates.tsx b/src/features/settings/components/CheckForUpdates.tsx
index b608910..3680edb 100644
--- a/src/features/settings/components/CheckForUpdates.tsx
+++ b/src/features/settings/components/CheckForUpdates.tsx
@@ -5,7 +5,7 @@ import { AlertCircle, CheckCircle2, Download, Info, Loader2, RefreshCw } from 'l
export default function CheckForUpdates() {
const { status, updateInfo, downloadProgress, error: updateError, checkForUpdates, downloadAndInstall, relaunchApp } = useUpdater();
return (
-
+
diff --git a/src/features/settings/components/ColorVariant.tsx b/src/features/settings/components/ColorVariant.tsx
index 7de6011..5f8f536 100644
--- a/src/features/settings/components/ColorVariant.tsx
+++ b/src/features/settings/components/ColorVariant.tsx
@@ -1,184 +1,60 @@
import { useThemeVariant } from '@/features/settings/hooks/useThemeVariant';
import { themeVariants, ThemeVariant } from "@/lib/themes";
-import { Check, Palette, Layers } from 'lucide-react';
+import { cn } from "@/lib/utils";
const ACCENT_THEMES: ThemeVariant[] = ['blue', 'slate', 'green', 'purple', 'orange', 'rose'];
const FULL_THEMES: ThemeVariant[] = ['cyberpunk', 'vscode', 'valorant', 'ghibli'];
-interface ThemeCardProps {
- themeKey: ThemeVariant;
- isActive: boolean;
- onSelect: () => void;
-}
-
-function ThemeCard({ themeKey, isActive, onSelect }: ThemeCardProps) {
- const config = themeVariants[themeKey];
-
- if (config.fullPalette) {
- // Determine which palette(s) to draw from for the preview
- const previewLight = config.lightPalette ?? config.palette;
- const previewDark = config.darkPalette ?? config.palette;
- const isDual = Boolean(config.lightPalette && config.darkPalette);
-
- return (
-
- {/* Preview banner — split for dual-mode themes */}
- {isDual ? (
-
- ) : (
-
- )}
-
- {/* Mini chrome mockup */}
-
-
- {/* Label */}
-
-
-
- {config.name}
-
- {config.description && (
-
- {config.description}
-
- )}
-
-
-
-
- {isActive && (
-
-
-
- )}
-
- );
- }
-
- // Simple swatch card for accent-only themes
- return (
-
-
- {isActive && (
-
- )}
-
- );
-}
-
export default function ColorVariant() {
const { variant, setVariant } = useThemeVariant();
return (
-
-
-
-
-
Theme
-
- Choose an accent color or a full UI theme
-
-
-
-
- {/* Accent colors */}
-
-
- Accent Colors
-
-
- {ACCENT_THEMES.map((key) => (
-
setVariant(key)}
- />
- ))}
+
+
+
Accent color
+
+ {ACCENT_THEMES.map((key) => {
+ const config = themeVariants[key];
+ const isActive = variant === key;
+ return (
+ setVariant(key)}
+ className={cn(
+ "w-7 h-7 rounded-full transition-all flex items-center justify-center",
+ isActive ? "ring-2 ring-white ring-offset-2 ring-offset-background" : "hover:scale-110"
+ )}
+ style={{ backgroundColor: config.primary }}
+ title={config.name}
+ aria-label={`Select ${config.name} accent`}
+ />
+ );
+ })}
- {/* Full palette themes */}
-
-
- Full Themes
-
-
- {FULL_THEMES.map((key) => (
-
setVariant(key)}
- />
- ))}
+ {/* Keeping Full Themes as they are custom themes and valuable */}
+
+
Full themes
+
+ {FULL_THEMES.map((key) => {
+ const config = themeVariants[key];
+ const isActive = variant === key;
+ return (
+ setVariant(key)}
+ className={cn(
+ "px-3 py-1.5 text-xs font-medium rounded-md transition-all border",
+ isActive
+ ? "bg-primary/20 text-primary border-primary"
+ : "bg-transparent text-muted-foreground border-border/20 hover:border-border/40 hover:text-foreground"
+ )}
+ >
+ {config.name}
+
+ );
+ })}
diff --git a/src/features/settings/components/DeveloperMode.tsx b/src/features/settings/components/DeveloperMode.tsx
index 61743bc..7dfe17e 100644
--- a/src/features/settings/components/DeveloperMode.tsx
+++ b/src/features/settings/components/DeveloperMode.tsx
@@ -5,7 +5,7 @@ import { useDeveloperMode } from '@/features/settings/hooks/useDeveloperMode';
export default function DeveloperMode() {
const { isEnabled: devModeEnabled, setIsEnabled: setDevModeEnabled } = useDeveloperMode();
return (
-
+
diff --git a/src/features/settings/components/Preview.tsx b/src/features/settings/components/Preview.tsx
deleted file mode 100644
index ed635b3..0000000
--- a/src/features/settings/components/Preview.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { Button } from '../../../components/ui/button'
-
-export default function Preview() {
- return (
-
- Preview
-
-
-
- Primary Button
-
-
- Outline Button
-
-
- Ghost Button
-
-
-
-
- This is a preview of how text and UI elements will look with your selected theme.
-
-
-
-
- )
-}
diff --git a/src/features/settings/components/SettingsDialog.tsx b/src/features/settings/components/SettingsDialog.tsx
new file mode 100644
index 0000000..6abae98
--- /dev/null
+++ b/src/features/settings/components/SettingsDialog.tsx
@@ -0,0 +1,107 @@
+import { useState } from "react";
+import { AISettings, AIHistoryPanel, CheckForUpdates, ColorVariant, DeveloperMode, ThemeMode, Version } from "@/features/settings/components";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
+
+type TabId = "general" | "ai" | "shortcuts" | "about";
+
+interface Tab {
+ id: TabId;
+ label: string;
+}
+
+const TABS: Tab[] = [
+ { id: "general", label: "General" },
+ { id: "ai", label: "AI Providers" },
+ { id: "shortcuts", label: "Shortcuts" },
+ { id: "about", label: "About" },
+];
+
+export interface SettingsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
+ const [activeTab, setActiveTab] = useState
("general");
+
+ return (
+
+ {/* Header */}
+
+ Settings
+
+
+ {/* Body */}
+
+ {/* Sidebar */}
+
{TABS.map((tab) => (
+ setActiveTab(tab.id)}
+ className={cn(
+ "h-9 w-full justify-start rounded-lg px-3 text-sm transition-colors duration-200",
+ activeTab === tab.id
+ ? "bg-primary/20 text-primary hover:bg-primary/20 hover:text-primary"
+ : "text-muted-foreground hover:text-foreground hover:bg-accent"
+ )}
+ >
+ {tab.label}
+
+ ))}
+
+
+ {/* Content Area */}
+
+
+ {activeTab === "general" && (
+
+
+
+
+
+ )}
+
+
+
+ {activeTab === "ai" && (
+
+ )}
+
+ {activeTab === "shortcuts" && (
+
+
Keyboard shortcuts will be available here.
+
+ )}
+
+ {activeTab === "about" && (
+
+
+
+
+
+ )}
+
+
+
+
+ {/* Footer */}
+
+ onOpenChange(false)}
+ >
+ Close
+
+
+
+
+ );
+}
diff --git a/src/features/settings/components/ThemeMode.tsx b/src/features/settings/components/ThemeMode.tsx
index ec9457b..3b52d52 100644
--- a/src/features/settings/components/ThemeMode.tsx
+++ b/src/features/settings/components/ThemeMode.tsx
@@ -1,62 +1,37 @@
-import { Theme } from '@tauri-apps/api/window';
-import { Check, LucideProps, Monitor, Moon, Sun } from 'lucide-react';
import { useTheme } from "@/components/providers/ThemeProvider";
+import type { Theme } from "@/components/providers/ThemeProvider";
+import { cn } from "@/lib/utils";
export default function ThemeMode() {
const { theme, setTheme } = useTheme();
const themeOptions = [
- { value: "light", label: "Light", icon: Sun },
- { value: "dark", label: "Dark", icon: Moon },
- { value: "system", label: "System", icon: Monitor },
+ { value: "dark", label: "Dark" },
+ { value: "light", label: "Light" },
+ { value: "system", label: "System" },
];
return (
-
-
-
-
-
Theme Mode
-
- Choose between light and dark mode
-
-
-
-
-
+
+ Theme
+
{themeOptions.map((option) => {
- const Icon = option.icon;
const isActive = theme === option.value;
-
return (
setTheme(option.value as Theme)}
- className={`
- relative p-4 rounded-lg border transition-all
- ${isActive
- ? "border-primary bg-primary/5"
- : "border-border/20 hover:border-border/40 bg-background"
- }
- `}
- >
-
-
-
- {option.label}
-
-
- {isActive && (
-
+ className={cn(
+ "px-4 py-1.5 text-sm font-medium rounded-md transition-all border",
+ isActive
+ ? "bg-primary/20 text-primary border-primary"
+ : "bg-transparent text-muted-foreground border-border/20 hover:border-border/40 hover:text-foreground"
)}
+ >
+ {option.label}
);
})}
- )
+ );
}
-
diff --git a/src/features/settings/components/Version.tsx b/src/features/settings/components/Version.tsx
index 842e77f..def4168 100644
--- a/src/features/settings/components/Version.tsx
+++ b/src/features/settings/components/Version.tsx
@@ -10,7 +10,7 @@ export default function Version() {
getVersion().then(setAppVersion).catch(() => setAppVersion("unknown"));
}, []);
return (
-
+
diff --git a/src/features/settings/components/index.tsx b/src/features/settings/components/index.tsx
index 70ae1b9..6196572 100644
--- a/src/features/settings/components/index.tsx
+++ b/src/features/settings/components/index.tsx
@@ -1,7 +1,9 @@
export { default as Header } from './Header'
export { default as ThemeMode } from './ThemeMode'
export { default as ColorVariant } from './ColorVariant'
-export {default as DeveloperMode} from './DeveloperMode'
-export {default as CheckForUpdates} from './CheckForUpdates'
-export {default as Version} from './Version'
-export {default as Preview} from './Preview'
\ No newline at end of file
+export { default as DeveloperMode } from './DeveloperMode'
+export { default as CheckForUpdates } from './CheckForUpdates'
+export { default as Version } from './Version'
+export { default as AISettings } from './AISettings'
+export { default as AIHistoryPanel } from '../../ai/components/AIHistoryPanel'
+export { SettingsDialog } from './SettingsDialog'
\ No newline at end of file
diff --git a/src/features/workspace/components/ExplainQueryButton.tsx b/src/features/workspace/components/ExplainQueryButton.tsx
new file mode 100644
index 0000000..717c03d
--- /dev/null
+++ b/src/features/workspace/components/ExplainQueryButton.tsx
@@ -0,0 +1,85 @@
+import { useState } from "react";
+import { Bot, Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { AIResultDialog } from "@/features/ai/components/AIResultDialog";
+import { useAISettings } from "@/features/ai/hooks/useAISettings";
+import { aiService } from "@/services/bridge/ai";
+
+interface ExplainQueryButtonProps {
+ sql: string;
+ disabled?: boolean;
+ databaseName?: string;
+}
+
+export function ExplainQueryButton({ sql, disabled, databaseName }: ExplainQueryButtonProps) {
+ const settings = useAISettings();
+ const [open, setOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [markdown, setMarkdown] = useState
();
+ const [error, setError] = useState(null);
+ const [cached, setCached] = useState();
+ const [createdAt, setCreatedAt] = useState();
+
+ const doExplain = async (skipCache = false) => {
+ setOpen(true);
+ setMarkdown(undefined);
+ setError(null);
+ setLoading(true);
+
+ try {
+ const result = await aiService.explainQuery(settings, {
+ sql: sql.trim(),
+ }, { skipCache, datasourceName: databaseName });
+ setMarkdown(result.markdown);
+ setCached(result.cached);
+ setCreatedAt(result.createdAt);
+ } catch (err: any) {
+ setError(err?.message ?? String(err));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleReanalyze = () => {
+ setCached(undefined);
+ setCreatedAt(undefined);
+ doExplain(true);
+ };
+
+ const shortSQL =
+ sql.trim().length > 60
+ ? sql.trim().slice(0, 60).replace(/\s+/g, " ") + "…"
+ : sql.trim().replace(/\s+/g, " ");
+
+ return (
+ <>
+ doExplain()}
+ disabled={disabled || !sql.trim()}
+ >
+ {loading ? (
+
+ ) : (
+
+ )}
+ Explain
+
+
+
+ >
+ );
+}
diff --git a/src/features/workspace/components/SQLWorkspacePanel.tsx b/src/features/workspace/components/SQLWorkspacePanel.tsx
index 38c1019..5e9a3f8 100644
--- a/src/features/workspace/components/SQLWorkspacePanel.tsx
+++ b/src/features/workspace/components/SQLWorkspacePanel.tsx
@@ -167,6 +167,7 @@ const SQLWorkspacePanel = ({ dbId }: SQLWorkspacePanelProps) => {
isExecuting={isExecuting}
queryProgress={queryProgress}
canExecute={!!activeTab?.query.trim()}
+ activeQuery={activeTab?.query}
onExecute={handleExecuteQuery}
onCancel={handleCancelQuery}
/>
diff --git a/src/features/workspace/components/WorkspaceHeader.tsx b/src/features/workspace/components/WorkspaceHeader.tsx
index 8b032b8..12b859a 100644
--- a/src/features/workspace/components/WorkspaceHeader.tsx
+++ b/src/features/workspace/components/WorkspaceHeader.tsx
@@ -1,11 +1,13 @@
import { Play, Square, Database, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { ExplainQueryButton } from "./ExplainQueryButton";
interface WorkspaceHeaderProps {
databaseName: string;
isExecuting: boolean;
queryProgress: { rows: number; elapsed: number } | null;
canExecute: boolean;
+ activeQuery?: string;
onExecute: () => void;
onCancel: () => void;
}
@@ -15,6 +17,7 @@ export function WorkspaceHeader({
isExecuting,
queryProgress,
canExecute,
+ activeQuery,
onExecute,
onCancel,
}: WorkspaceHeaderProps) {
@@ -38,6 +41,13 @@ export function WorkspaceHeader({
)}
+ {/* AI Explain Query */}
+
+
{/* Run/Stop buttons */}
{isExecuting ? (
(0);
+
+ useEffect(() => {
+ if (typeof end === 'string') {
+ setCount(end);
+ return;
+ }
+
+ if (end === 0) {
+ setCount(0);
+ return;
+ }
+
+ const preferReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ if (preferReducedMotion) {
+ setCount(end);
+ return;
+ }
+
+ let startTimestamp: number | null = null;
+ const step = (timestamp: number) => {
+ if (!startTimestamp) startTimestamp = timestamp;
+ const progress = Math.min((timestamp - startTimestamp) / durationMs, 1);
+
+ // easeOutExpo
+ const easeProgress = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress);
+
+ setCount(Math.floor(easeProgress * end));
+
+ if (progress < 1) {
+ window.requestAnimationFrame(step);
+ } else {
+ setCount(end);
+ }
+ };
+
+ window.requestAnimationFrame(step);
+
+ return () => {
+ // cleanup is automatic as requestAnimationFrame will stop
+ };
+ }, [end, durationMs]);
+
+ return count;
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index bd0c391..19215ca 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -4,3 +4,40 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+
+/**
+ * Format an ISO timestamp string to a readable local date/time.
+ * e.g. "2026-06-12T12:46:20.671Z" → "12 Jun 2026, 12:46 pm"
+ */
+export function formatTimestamp(dateString?: string | null): string {
+ if (!dateString) return "—";
+ const date = new Date(dateString);
+ if (isNaN(date.getTime())) return dateString;
+ return date.toLocaleString(undefined, {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: true,
+ });
+}
+
+/**
+ * Show relative time for recent events, fall back to formatTimestamp for older ones.
+ * e.g. "5m ago", "2h ago", "3d ago", or "12 Jun 2026, 12:46 pm"
+ */
+export function formatRelativeTime(dateString?: string | null): string {
+ if (!dateString) return "Never";
+ const date = new Date(dateString);
+ if (isNaN(date.getTime())) return dateString;
+ const diffMs = Date.now() - date.getTime();
+ const diffMins = Math.floor(diffMs / 60000);
+ const diffHours = Math.floor(diffMs / 3600000);
+ const diffDays = Math.floor(diffMs / 86400000);
+ if (diffMins < 1) return "Just now";
+ if (diffMins < 60) return `${diffMins}m ago`;
+ if (diffHours < 24) return `${diffHours}h ago`;
+ if (diffDays < 7) return `${diffDays}d ago`;
+ return formatTimestamp(dateString);
+}
diff --git a/src/main.tsx b/src/main.tsx
index 4570e0d..15bab9d 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -5,17 +5,14 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom";
import Index from "./pages/Index";
import DatabaseDetail from './pages/DatabaseDetails';
-import Projects from './pages/Projects';
import NotFound from './pages/NotFound';
import { ThemeProvider } from './components/providers/ThemeProvider';
-import Settings from './pages/Settings';
import { useBridgeInit } from "@/services/bridge/useBridgeInit";
import { useEffect, useState } from 'react';
import { DeveloperContextMenu } from './components/dev/DeveloperContextMenu';
import { UpdateNotification } from './components/shared/UpdateNotification';
import { WhatsNewDialog } from './components/shared/WhatsNewDialog';
import TitleBar from './components/layout/TitleBar';
-import VerticalIconBar from './components/layout/VerticalIconBar';
import { CommandPalette } from './components/layout/CommandPalette';
const queryClient = new QueryClient();
@@ -38,16 +35,7 @@ function ThemeVariantInitializer() {
return null;
}
-function GlobalSidebar() {
- const location = useLocation();
- const showOnGlobalRoutes = ['/', '/projects', '/settings'].includes(location.pathname);
-
- if (!showOnGlobalRoutes) {
- return null;
- }
- return ;
-}
function AnimatedRoutes() {
const location = useLocation();
@@ -72,9 +60,7 @@ function AnimatedRoutes() {
} />
- } />
} />
- } />
} />
@@ -110,7 +96,6 @@ function AppRoot() {
diff --git a/src/pages/DatabaseDetails.tsx b/src/pages/DatabaseDetails.tsx
index 60529ab..87a7ba8 100644
--- a/src/pages/DatabaseDetails.tsx
+++ b/src/pages/DatabaseDetails.tsx
@@ -29,6 +29,7 @@ import GitStatusBar from "@/features/git/components/GitStatusBar";
import { MonitoringPanel } from "@/features/monitoring/components/MonitoringPanel";
import { ShortcutsHelp } from "@/components/shared/ShortcutsHelp";
import { ShortcutsTrigger } from "@/components/shared/ShortcutsTrigger";
+import { MigrationSyncDialog } from "@/features/project/components/MigrationSyncDialog";
import { useState } from "react";
const DatabaseDetail = () => {
@@ -94,9 +95,10 @@ const DatabaseDetail = () => {
const baselined = migrationsResponse?.baselined || false;
// Project sync
- const { data: schemaData } = useFullSchema(dbId);
- const { projectId } = useProjectSync(dbId, schemaData ?? undefined);
+ const { data: schemaData, refetch: refetchSchema } = useFullSchema(dbId);
+ const { projectId, importAnalysis, importAnalysisLoading, refetchImportAnalysis } = useProjectSync(dbId, schemaData ?? undefined);
const { data: projectDir } = useProjectDir(projectId);
+ const [migrationSyncDismissed, setMigrationSyncDismissed] = useState(false);
// ---- Guards ----
if (bridgeLoading || bridgeReady === undefined) return ;
@@ -110,7 +112,8 @@ const DatabaseDetail = () => {
case "schema-explorer": return ;
case "er-diagram": return ;
case "monitoring": return ;
- case "git-status": return ;
+ case "git-status": return ;
+ case "migrations": return
;
default:
return (
{
sidebarOpen={sidebarOpen}
onToggleSidebar={toggleSidebar}
onRefresh={fetchTables}
- onMigrationsOpen={() => setMigrationsOpen(true)}
+ onMigrationsOpen={() => setActivePanel("migrations")}
onExport={exportAllTables}
isExporting={isExporting}
onChart={() => setChartOpen(true)}
@@ -183,10 +186,20 @@ const DatabaseDetail = () => {
- {/* Migrations */}
- setMigrationsOpen(false)} title="Migrations" disableScroll>
-
-
+ {/* Migration Sync Dialog — prompts user when imported project has pending migrations */}
+ {projectId && !migrationSyncDismissed && !importAnalysisLoading && (
+ setMigrationSyncDismissed(true)}
+ onApplied={() => {
+ setMigrationSyncDismissed(true);
+ refetchImportAnalysis();
+ fetchTables();
+ refetchSchema();
+ }}
+ />
+ )}
{/* Chart */}
setChartOpen(false)} title={`Chart: ${selectedTable?.name || "Table"}`} width="60%">
diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx
index 5b4e8d5..4402950 100644
--- a/src/pages/Index.tsx
+++ b/src/pages/Index.tsx
@@ -6,8 +6,9 @@ import {
DatabaseDetail,
WelcomeView,
AddConnectionDialog,
- DeleteDialog,
+ DeleteConnectionDialog,
} from "@/features/home/components";
+import { ImportProjectDialog } from "@/features/project/components";
import BridgeLoader from "@/components/feedback/BridgeLoader";
import BridgeFailed from "@/components/feedback/BridgeFailed";
import { useIndexPage } from "@/features/home/hooks/useIndexPage";
@@ -34,14 +35,13 @@ const Index = () => {
// Separated so hooks only run after bridge is ready
const IndexContent = ({ bridgeReady, onShortcutsClick }: { bridgeReady: boolean, onShortcutsClick: () => void }) => {
const {
- // Data
databases,
filteredDatabases,
recentDatabases,
+ unlinkedProjects,
selectedDatabase,
selectedDbStats,
loading,
- welcomeMessage,
// Status + stats
status,
@@ -57,36 +57,45 @@ const IndexContent = ({ bridgeReady, onShortcutsClick }: { bridgeReady: boolean,
// UI state
searchQuery,
setSearchQuery,
+ onlineFilter,
+ setOnlineFilter,
selectedDb,
setSelectedDb,
isDialogOpen,
deleteDialogOpen,
setDeleteDialogOpen,
- dbToDelete,
+ deleteConnectionDialogProps,
prefilledConnectionData,
// Handlers
handleAddDatabase,
- handleDeleteDatabase,
handleTestConnection,
handleDatabaseClick,
handleDatabaseHover,
handleDiscoveredDatabaseAdd,
handleDialogClose,
openDeleteDialog,
+
+ // Import
+ isImportOpen,
+ setIsImportOpen,
+ handleImportComplete,
} = useIndexPage(bridgeReady);
return (
-
+
{/* Left Panel */}
setIsImportOpen(true)}
/>
{/* Right Panel */}
@@ -120,12 +130,12 @@ const IndexContent = ({ bridgeReady, onShortcutsClick }: { bridgeReady: boolean,
connectedCount={connectedCount}
totalTables={totalTables}
totalSize={totalSize}
- welcomeMessage={welcomeMessage}
statsLoading={showStatsLoading}
onAddClick={() => handleDialogClose(true)}
onSelectDb={setSelectedDb}
onDatabaseHover={handleDatabaseHover}
onDiscoveredDatabaseAdd={handleDiscoveredDatabaseAdd}
+ onOnlineFilterClick={() => setOnlineFilter(true)}
/>
)}
@@ -150,11 +160,18 @@ const IndexContent = ({ bridgeReady, onShortcutsClick }: { bridgeReady: boolean,
initialData={prefilledConnectionData}
/>
-
+ )}
+
+
);
diff --git a/src/pages/Projects.tsx b/src/pages/Projects.tsx
deleted file mode 100644
index 2659e62..0000000
--- a/src/pages/Projects.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-import { useBridgeQuery } from "@/services/bridge/useBridgeQuery";
-import { useProjectsPage } from "@/features/project/hooks/useProjectsPage";
-import {
- ProjectList,
- CreateProjectDialog,
- DeleteProjectDialog,
- ImportProjectDialog,
- ProjectDetailView,
-} from "@/features/project/components";
-import { ProjectsEmptyState } from "@/features/project/components/ProjectsEmptyState";
-import BridgeLoader from "@/components/feedback/BridgeLoader";
-import BridgeFailed from "@/components/feedback/BridgeFailed";
-
-const Projects = () => {
- const { data: bridgeReady, isLoading: bridgeLoading } = useBridgeQuery();
-
- // All logic lives in the hook
- const {
- projects,
- databases,
- filteredProjects,
- selectedProjectData,
- projectsLoading,
- schemaData,
- erData,
- queriesData,
- isCreating,
- searchQuery,
- setSearchQuery,
- selectedProject,
- setSelectedProject,
- isCreateOpen,
- setIsCreateOpen,
- isImportOpen,
- setIsImportOpen,
- deleteDialogOpen,
- setDeleteDialogOpen,
- projectToDelete,
- handleCreate,
- handleDelete,
- handleExport,
- handleImportComplete,
- handleOpen,
- openDeleteDialog,
- } = useProjectsPage();
-
- // ---- Bridge guard — only logic allowed in page ----
- if (bridgeLoading || bridgeReady === undefined) return ;
- if (!bridgeReady) return ;
-
- return (
-
-
- {/* Left panel */}
- setIsCreateOpen(true)}
- onImportClick={() => setIsImportOpen(true)}
- onDelete={openDeleteDialog}
- onOpen={handleOpen}
- />
-
- {/* Right panel */}
-
- {selectedProjectData ? (
-
0}
- onOpen={() => handleOpen(selectedProjectData.id)}
- onDelete={() => openDeleteDialog(selectedProjectData.id, selectedProjectData.name)}
- onExport={() => handleExport(selectedProjectData.id)}
- onBack={() => setSelectedProject(null)}
- />
- ) : (
- 0}
- onCreateClick={() => setIsCreateOpen(true)}
- onImportClick={() => setIsImportOpen(true)}
- />
- )}
-
-
-
- {/* Dialogs */}
-
-
-
-
-
-
- );
-};
-
-export default Projects;
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
deleted file mode 100644
index d8867f0..0000000
--- a/src/pages/Settings.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { CheckForUpdates, ColorVariant, DeveloperMode, Header, Preview, ThemeMode, Version } from "@/features/settings/components";
-
-const Settings = () => {
- return (
-
-
- {/* Header */}
-
-
- {/* Content */}
-
-
- {/* Theme Mode Section */}
-
-
- {/* Color Variant Section */}
-
-
- {/* Developer Mode Section */}
-
-
- {/* Updates Section */}
-
-
- {/* About Section */}
-
-
- {/* Preview Section */}
-
-
-
-
-
- );
-};
-
-export default Settings;
diff --git a/src/services/bridge/ai.ts b/src/services/bridge/ai.ts
new file mode 100644
index 0000000..e3e3784
--- /dev/null
+++ b/src/services/bridge/ai.ts
@@ -0,0 +1,251 @@
+import { bridgeRequest } from "./bridgeClient";
+
+// ── Re-export the types that the frontend needs ───────────────────────────
+// (These mirror the bridge types but kept local to avoid importing from bridge)
+
+export type AIProviderName =
+ | "anthropic"
+ | "openai"
+ | "gemini"
+ | "groq"
+ | "mistral"
+ | "ollama";
+
+export interface AISettings {
+ defaultProvider: AIProviderName;
+ anthropicApiKey?: string;
+ openaiApiKey?: string;
+ geminiApiKey?: string;
+ groqApiKey?: string;
+ mistralApiKey?: string;
+ ollamaBaseUrl?: string;
+ ollamaModel?: string;
+}
+
+export interface SchemaAnalysisInput {
+ tables: Array<{
+ name: string;
+ schema?: string;
+ columns: Array<{
+ name: string;
+ type: string;
+ nullable?: boolean;
+ isPrimaryKey?: boolean;
+ isForeignKey?: boolean;
+ references?: { table: string; column: string };
+ }>;
+ indexes?: string[];
+ foreignKeys?: string[];
+ constraints?: string[];
+ }>;
+ databaseType?: string;
+}
+
+export interface QueryExplanationInput {
+ sql: string;
+ schema?: SchemaAnalysisInput["tables"];
+ databaseType?: string;
+}
+
+export interface ChartRecommendationInput {
+ tableName: string;
+ columns: Array<{
+ name: string;
+ type: string;
+ isPrimaryKey?: boolean;
+ sampleValues?: string[];
+ }>;
+}
+
+export interface ChartRecommendation {
+ chartType: "bar" | "line" | "area" | "pie";
+ xAxis: string;
+ yAxis: string;
+ reasoning: string;
+}
+
+// ── Cache-aware response types ────────────────────────────────────────────
+
+export interface AIAnalysisResult {
+ markdown: string;
+ cached: boolean;
+ createdAt?: string;
+}
+
+export interface AIChartResult extends ChartRecommendation {
+ cached: boolean;
+ createdAt?: string;
+}
+
+// ── History types ─────────────────────────────────────────────────────────
+
+export interface AIHistoryListItem {
+ id: number;
+ feature: string;
+ datasource_id: string | null;
+ table_name: string | null;
+ provider: string;
+ model: string;
+ tokens_used: number | null;
+ created_at: string;
+}
+
+export interface AIHistoryEntry {
+ id: number;
+ feature: string;
+ datasource_id: string | null;
+ table_name: string | null;
+ content_hash: string | null;
+ provider: string;
+ model: string;
+ prompt: string;
+ response: string;
+ tokens_used: number | null;
+ created_at: string;
+}
+
+export interface AIHistoryListResult {
+ items: AIHistoryListItem[];
+ total: number;
+}
+
+// ── AI Settings storage ───────────────────────────────────────────────────
+
+const AI_SETTINGS_KEY = "relwave:ai-settings";
+
+export function loadAISettings(): AISettings {
+ try {
+ const raw = localStorage.getItem(AI_SETTINGS_KEY);
+ if (raw) return JSON.parse(raw) as AISettings;
+ } catch { /* ignore */ }
+ return { defaultProvider: "ollama" };
+}
+
+export function saveAISettings(settings: AISettings): void {
+ localStorage.setItem(AI_SETTINGS_KEY, JSON.stringify(settings));
+}
+
+// ── Bridge service class ──────────────────────────────────────────────────
+
+class AIService {
+ /**
+ * Test whether the configured provider is reachable.
+ */
+ async testConnection(settings: AISettings): Promise<{ connected: boolean; message?: string }> {
+ try {
+ await bridgeRequest("ai.testConnection", { settings });
+ return { connected: true };
+ } catch (err: any) {
+ return { connected: false, message: err?.message ?? String(err) };
+ }
+ }
+
+ /**
+ * Analyze a database schema. Returns markdown + cache metadata.
+ */
+ async analyzeSchema(
+ settings: AISettings,
+ input: SchemaAnalysisInput,
+ opts?: { skipCache?: boolean; datasourceName?: string }
+ ): Promise {
+ const result = await bridgeRequest("ai.analyzeSchema", {
+ settings,
+ input,
+ skipCache: opts?.skipCache,
+ datasourceName: opts?.datasourceName,
+ });
+ return {
+ markdown: result?.data?.markdown ?? "",
+ cached: result?.data?.cached ?? false,
+ createdAt: result?.data?.createdAt,
+ };
+ }
+
+ /**
+ * Explain a SQL query. Returns markdown + cache metadata.
+ */
+ async explainQuery(
+ settings: AISettings,
+ input: QueryExplanationInput,
+ opts?: { skipCache?: boolean; datasourceName?: string }
+ ): Promise {
+ const result = await bridgeRequest("ai.explainQuery", {
+ settings,
+ input,
+ skipCache: opts?.skipCache,
+ datasourceName: opts?.datasourceName,
+ });
+ return {
+ markdown: result?.data?.markdown ?? "",
+ cached: result?.data?.cached ?? false,
+ createdAt: result?.data?.createdAt,
+ };
+ }
+
+ /**
+ * Recommend a chart type and axes for the given table metadata.
+ */
+ async recommendChart(
+ settings: AISettings,
+ input: ChartRecommendationInput,
+ opts?: { skipCache?: boolean; datasourceName?: string }
+ ): Promise {
+ const result = await bridgeRequest("ai.recommendChart", {
+ settings,
+ input,
+ skipCache: opts?.skipCache,
+ datasourceName: opts?.datasourceName,
+ tableName: input.tableName,
+ });
+ const data = result?.data;
+ return {
+ chartType: data?.chartType ?? "bar",
+ xAxis: data?.xAxis ?? "",
+ yAxis: data?.yAxis ?? "",
+ reasoning: data?.reasoning ?? "",
+ cached: data?.cached ?? false,
+ createdAt: data?.createdAt,
+ };
+ }
+
+ // ── History methods ─────────────────────────────────────────────────────
+
+ /**
+ * List AI analysis history with optional filters and pagination.
+ */
+ async getHistory(params?: {
+ feature?: string;
+ provider?: string;
+ limit?: number;
+ offset?: number;
+ }): Promise {
+ const result = await bridgeRequest("ai.getHistory", params ?? {});
+ return result?.data as AIHistoryListResult;
+ }
+
+ /**
+ * Get a single history entry by ID (full record with prompt/response).
+ */
+ async getHistoryById(id: number): Promise {
+ const result = await bridgeRequest("ai.getHistoryById", { id });
+ return result?.data as AIHistoryEntry;
+ }
+
+ /**
+ * Delete a single history entry by ID.
+ */
+ async deleteHistory(id: number): Promise {
+ const result = await bridgeRequest("ai.deleteHistory", { id });
+ return result?.data?.deleted ?? false;
+ }
+
+ /**
+ * Clear all history entries.
+ */
+ async clearHistory(): Promise {
+ const result = await bridgeRequest("ai.clearHistory", {});
+ return result?.data?.deletedCount ?? 0;
+ }
+}
+
+export const aiService = new AIService();
diff --git a/src/services/bridge/bridgeClient.ts b/src/services/bridge/bridgeClient.ts
index 5ed3b89..a2cc9e3 100644
--- a/src/services/bridge/bridgeClient.ts
+++ b/src/services/bridge/bridgeClient.ts
@@ -16,11 +16,12 @@ let lastHealthCheckTime = Date.now();
let connectionHealthy = true;
let healthCheckInterval: ReturnType | null = null;
let reconnectAttempts = 0;
-let isReconnecting = false; // Prevent concurrent reconnection attempts
-const MAX_RECONNECT_ATTEMPTS = 3;
+let reconnectPromise: Promise | null = null; // Replaces isReconnecting flag
+const MAX_RECONNECT_ATTEMPTS = 5;
const HEALTH_CHECK_INTERVAL = 30000; // 30 seconds
-const CONNECTION_TIMEOUT = 60000; // Consider unhealthy after 60s of no successful requests
+const CONNECTION_TIMEOUT = 120000; // Consider unhealthy after 120s of no successful requests
const SLEEP_DETECTION_THRESHOLD = 60000; // If interval fires 60s+ late, system likely slept
+const HEALTH_PING_METHOD = "health.ping";
// Listeners for connection state changes
type ConnectionStateListener = (healthy: boolean) => void;
@@ -32,8 +33,21 @@ export function onConnectionStateChange(listener: ConnectionStateListener): () =
}
function notifyConnectionState(healthy: boolean) {
- connectionHealthy = healthy;
- connectionStateListeners.forEach(listener => listener(healthy));
+ if (connectionHealthy !== healthy) {
+ console.log(`bridgeClient: Connection state changed: ${connectionHealthy} -> ${healthy}`);
+ connectionHealthy = healthy;
+ connectionStateListeners.forEach(listener => listener(healthy));
+ }
+}
+
+function rejectPendingRequests(reason: string): void {
+ if (pending.size === 0) return;
+
+ const error = new Error(reason);
+ for (const { reject } of pending.values()) {
+ reject(error);
+ }
+ pending.clear();
}
/** Check if we're running in Tauri environment */
@@ -65,6 +79,9 @@ export async function startBridgeListeners(): Promise {
try {
const payload = JSON.parse(event.payload);
if (payload && typeof payload === "object") {
+ // Track activity - any valid JSON from bridge is a sign of life
+ lastSuccessfulRequest = Date.now();
+
// Handle notification (no id field)
if (payload.method && payload.id === undefined) {
window.dispatchEvent(
@@ -85,9 +102,8 @@ export async function startBridgeListeners(): Promise {
pending.delete(payload.id);
if ("result" in payload) {
// Track successful response - connection is healthy
- lastSuccessfulRequest = Date.now();
if (!connectionHealthy) {
- console.log("bridgeClient: Connection restored");
+ console.log("bridgeClient: Connection restored by response");
notifyConnectionState(true);
reconnectAttempts = 0;
}
@@ -106,11 +122,14 @@ export async function startBridgeListeners(): Promise {
// Listen to bridge stderr for logs
unlistenStderr = await listen("bridge-stderr", (event) => {
console.debug("bridge-log:", event.payload);
+ // Even logs are a sign of life
+ lastSuccessfulRequest = Date.now();
});
isInitialized = true;
- connectionHealthy = true;
+ notifyConnectionState(true);
lastSuccessfulRequest = Date.now();
+ reconnectAttempts = 0; // Reset on manual/init start
// Start health check interval
startHealthCheck();
@@ -149,7 +168,7 @@ function setupVisibilityHandler(): void {
try {
const status = await invoke("bridge_status");
if (status === "running") {
- await bridgeRequestInternal("ping", {}, 5000);
+ await bridgeRequestInternal(HEALTH_PING_METHOD, {}, 5000);
console.log("bridgeClient: Connection OK after visibility change");
lastSuccessfulRequest = Date.now();
if (!connectionHealthy) {
@@ -157,12 +176,15 @@ function setupVisibilityHandler(): void {
reconnectAttempts = 0;
}
} else {
- console.warn("bridgeClient: Bridge not running after visibility change");
+ console.warn(`bridgeClient: Bridge not running after visibility change (status: ${status})`);
await handleBridgeReconnect();
}
} catch (error) {
console.warn("bridgeClient: Connection check failed after visibility change:", error);
- // Don't immediately reconnect - the health check will handle it if needed
+ // If it's a pipe error, try to reconnect
+ if (isPipeError(error)) {
+ await handleBridgeReconnect();
+ }
}
}
};
@@ -193,19 +215,17 @@ function startHealthCheck(): void {
reconnectAttempts = 0;
// Wait a moment for system to stabilize after wake
- await new Promise(resolve => setTimeout(resolve, 2000));
+ await new Promise(resolve => setTimeout(resolve, 3000));
// Do a gentle health check
try {
const status = await invoke("bridge_status");
if (status === "running") {
// Try a ping to verify connection is actually working
- await bridgeRequestInternal("ping", {}, 10000);
+ await bridgeRequestInternal(HEALTH_PING_METHOD, {}, 10000);
console.log("bridgeClient: Connection verified after wake");
lastSuccessfulRequest = Date.now();
- if (!connectionHealthy) {
- notifyConnectionState(true);
- }
+ notifyConnectionState(true);
} else {
console.warn(`bridgeClient: Bridge not running after wake (status: ${status})`);
await handleBridgeReconnect();
@@ -217,11 +237,18 @@ function startHealthCheck(): void {
return;
}
- // Normal health check - only if no recent successful communication
- const timeSinceLastSuccess = now - lastSuccessfulRequest;
+ // Reconnection logic for unhealthy state
+ if (!connectionHealthy) {
+ console.warn("bridgeClient: Connection is currently unhealthy, attempting background recovery...");
+ await handleBridgeReconnect();
+ return;
+ }
+
+ // Normal health check - only if no recent activity
+ const timeSinceLastActivity = now - lastSuccessfulRequest;
- if (timeSinceLastSuccess > CONNECTION_TIMEOUT && connectionHealthy) {
- console.warn("bridgeClient: No recent successful requests, checking health...");
+ if (timeSinceLastActivity > CONNECTION_TIMEOUT) {
+ console.warn("bridgeClient: No recent activity, checking health...");
// First check if the bridge process is still running
try {
@@ -238,41 +265,40 @@ function startHealthCheck(): void {
// If process is running, try a ping
try {
- // Try a simple ping request - the bridge uses "ping" not "session.ping"
- await bridgeRequestInternal("ping", {}, 5000);
+ await bridgeRequestInternal(HEALTH_PING_METHOD, {}, 10000);
lastSuccessfulRequest = Date.now();
- // Reset reconnect attempts on successful health check
reconnectAttempts = 0;
+ notifyConnectionState(true);
} catch (error) {
console.error("bridgeClient: Health check ping failed", error);
notifyConnectionState(false);
-
- // If ping failed, try to restart
- if (isPipeError(error)) {
- await handleBridgeReconnect();
- }
+ // The next interval run will trigger handleBridgeReconnect
}
}
}, HEALTH_CHECK_INTERVAL);
}
+
/**
* Handle bridge reconnection with deduplication
*/
async function handleBridgeReconnect(): Promise {
- if (isReconnecting) {
- console.log("bridgeClient: Reconnection already in progress, skipping...");
- return;
+ if (reconnectPromise) {
+ console.log("bridgeClient: Reconnection already in progress, waiting...");
+ return reconnectPromise;
}
- isReconnecting = true;
- try {
- await reinitializeBridge();
- } catch (reinitError) {
- console.error("bridgeClient: Failed to reinitialize:", reinitError);
- } finally {
- isReconnecting = false;
- }
+ reconnectPromise = (async () => {
+ try {
+ await reinitializeBridge();
+ } catch (reinitError) {
+ console.error("bridgeClient: Failed to reinitialize:", reinitError);
+ } finally {
+ reconnectPromise = null;
+ }
+ })();
+
+ return reconnectPromise;
}
/**
@@ -281,6 +307,8 @@ async function handleBridgeReconnect(): Promise {
* - Call this when your component unmounts
*/
export function stopBridgeListeners(): void {
+ rejectPendingRequests("Bridge listeners stopped before the request completed.");
+
if (healthCheckInterval) {
clearInterval(healthCheckInterval);
healthCheckInterval = null;
@@ -299,7 +327,7 @@ export function stopBridgeListeners(): void {
}
isInitialized = false;
connectionHealthy = false;
- isReconnecting = false;
+ reconnectPromise = null;
console.log("bridgeClient: Listeners stopped");
}
@@ -308,7 +336,7 @@ export function stopBridgeListeners(): void {
*/
function getTimeoutForMethod(method: string): number {
// Ping should be fast
- if (method === "ping" || method === "health.ping") return 5000; // 5 seconds
+ if (method === "ping" || method === HEALTH_PING_METHOD) return 5000; // 5 seconds
// Schema operations can be very slow on large databases
if (method === "db.getSchema") return 180000; // 3 minutes
@@ -442,7 +470,7 @@ export async function bridgeRequest(
// Try to reinitialize the connection
try {
- await reinitializeBridge();
+ await handleBridgeReconnect();
} catch (reinitError) {
console.error("[Bridge] Failed to reinitialize:", reinitError);
}
diff --git a/src/services/bridge/project.ts b/src/services/bridge/project.ts
index e80014c..ba7b62d 100644
--- a/src/services/bridge/project.ts
+++ b/src/services/bridge/project.ts
@@ -108,6 +108,34 @@ class ProjectService {
}
}
+ /**
+ * Delete a project while cleaning up the filesystem and connection
+ */
+ async deleteWithConnection(projectId: string): Promise {
+ try {
+ if (!projectId) throw new Error("Project ID is required");
+ await bridgeRequest("project.deleteWithConnection", { projectId });
+ } catch (error: any) {
+ console.error("Failed to delete project with connection:", error);
+ throw new Error(`Failed to delete project: ${error.message}`);
+ }
+ }
+
+ /**
+ * Relink an unlinked project to an existing database connection
+ */
+ async relinkToConnection(projectId: string, databaseId: string): Promise {
+ try {
+ if (!projectId || !databaseId) throw new Error("Project ID and Database ID are required");
+ const result = await bridgeRequest("project.relinkToConnection", { projectId, databaseId });
+ if (!result?.data) throw new Error("Failed to relink project");
+ return result.data;
+ } catch (error: any) {
+ console.error("Failed to relink project:", error);
+ throw new Error(`Failed to relink project: ${error.message}`);
+ }
+ }
+
/**
* Get cached schema for a project
*/
@@ -344,6 +372,131 @@ class ProjectService {
throw new Error(`Failed to link database: ${error.message}`);
}
}
+ /**
+ * Analyze a project import to see if there are pending migrations or schema drift
+ */
+ async analyzeImport(projectId: string): Promise {
+ try {
+ if (!projectId) throw new Error("projectId is required");
+ const result = await bridgeRequest("project.analyzeImport", { projectId });
+ return result?.data;
+ } catch (error: any) {
+ console.error("Failed to analyze import:", error);
+ throw new Error(`Failed to analyze import: ${error.message}`);
+ }
+ }
+
+ /**
+ * Apply all pending migrations for a project
+ */
+ async applyMigrations(projectId: string, options?: { skipDestructive?: boolean }): Promise {
+ try {
+ if (!projectId) throw new Error("projectId is required");
+ const result = await bridgeRequest("migration.applyMigrations", { projectId, ...options });
+ return result?.data;
+ } catch (error: any) {
+ console.error("Failed to apply migrations:", error);
+ throw new Error(`Failed to apply migrations: ${error.message}`);
+ }
+ }
+
+ /**
+ * Apply schema snapshot baseline
+ */
+ async applySnapshot(projectId: string): Promise {
+ try {
+ if (!projectId) throw new Error("projectId is required");
+ const result = await bridgeRequest("migration.applySnapshot", { projectId });
+ return result?.data;
+ } catch (error: any) {
+ console.error("Failed to apply snapshot:", error);
+ throw new Error(`Failed to apply snapshot: ${error.message}`);
+ }
+ }
+
+ /**
+ * Re-capture live database schema and update schema.json
+ */
+ async refreshSchemaCache(projectId: string): Promise {
+ try {
+ if (!projectId) throw new Error("projectId is required");
+ const result = await bridgeRequest("project.refreshSchemaCache", { projectId });
+ return result?.data;
+ } catch (error: any) {
+ console.error("Failed to refresh schema cache:", error);
+ throw new Error(`Failed to refresh schema cache: ${error.message}`);
+ }
+ }
+
+ /**
+ * Verify migration lock file
+ */
+ async verifyLock(projectId: string): Promise {
+ try {
+ if (!projectId) throw new Error("projectId is required");
+ const result = await bridgeRequest("project.verifyLock", { projectId });
+ return result?.isValid;
+ } catch (error: any) {
+ console.error("Failed to verify lock:", error);
+ throw new Error(`Failed to verify lock: ${error.message}`);
+ }
+ }
+
+ /**
+ * Get drift detection between schema.json and live DB
+ */
+ async getDrift(projectId: string): Promise {
+ try {
+ if (!projectId) throw new Error("projectId is required");
+ const result = await bridgeRequest("project.getDrift", { projectId });
+ return result?.data;
+ } catch (error: any) {
+ console.error("Failed to get drift:", error);
+ throw new Error(`Failed to get drift: ${error.message}`);
+ }
+ }
+
+ /**
+ * Push migrations manually
+ */
+ async pushMigrations(projectId: string): Promise {
+ try {
+ if (!projectId) throw new Error("projectId is required");
+ const result = await bridgeRequest("project.pushMigrations", { projectId });
+ return result?.data;
+ } catch (error: any) {
+ console.error("Failed to push migrations:", error);
+ throw new Error(`Failed to push migrations: ${error.message}`);
+ }
+ }
+
+ /**
+ * Sync migrations (stage, commit, push)
+ */
+ async syncMigrations(projectId: string): Promise {
+ try {
+ if (!projectId) throw new Error("projectId is required");
+ const result = await bridgeRequest("project.syncMigrations", { projectId });
+ return result?.data;
+ } catch (error: any) {
+ console.error("Failed to sync migrations:", error);
+ throw new Error(`Failed to sync migrations: ${error.message}`);
+ }
+ }
+
+ /**
+ * Generate SQL string from schema snapshot
+ */
+ async generateSQL(projectId: string): Promise {
+ try {
+ if (!projectId) throw new Error("projectId is required");
+ const result = await bridgeRequest("project.generateSQL", { projectId });
+ return result?.data?.sql || "";
+ } catch (error: any) {
+ console.error("Failed to generate SQL:", error);
+ throw new Error(`Failed to generate SQL: ${error.message}`);
+ }
+ }
}
export const projectService = new ProjectService();
\ No newline at end of file