From 2f5412b15c2d31b6d681821c2ec16592a159a3de Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 8 Jun 2026 14:43:09 +0530 Subject: [PATCH 01/17] docs: add documentation for bridge architecture --- bridge/src/connectors/README.md | 26 +++++++++++++++++++++++ bridge/src/handlers/README.md | 33 +++++++++++++++++++++++++++++ bridge/src/queries/README.md | 26 +++++++++++++++++++++++ bridge/src/services/README.md | 37 +++++++++++++++++++++++++++++++++ bridge/src/types/README.md | 28 +++++++++++++++++++++++++ bridge/src/utils/README.md | 27 ++++++++++++++++++++++++ 6 files changed, 177 insertions(+) create mode 100644 bridge/src/connectors/README.md create mode 100644 bridge/src/handlers/README.md create mode 100644 bridge/src/queries/README.md create mode 100644 bridge/src/services/README.md create mode 100644 bridge/src/types/README.md create mode 100644 bridge/src/utils/README.md 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/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/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/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/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/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`. From 6c54cd6561bc6f464f25523fcc0b9dfbd1baa649 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 8 Jun 2026 20:28:35 +0530 Subject: [PATCH 02/17] feat: increase max tokens to 4096 for AI provider models --- bridge/src/ai/prompts/schema-analysis.ts | 27 +++++++++---------- bridge/src/ai/providers/anthropic.provider.ts | 2 +- bridge/src/ai/providers/gemini.provider.ts | 1 + bridge/src/ai/providers/groq.provider.ts | 2 +- bridge/src/ai/providers/mistral.provider.ts | 2 +- bridge/src/ai/providers/ollama.provider.ts | 1 + bridge/src/ai/providers/openai.provider.ts | 2 +- 7 files changed, 18 insertions(+), 19 deletions(-) diff --git a/bridge/src/ai/prompts/schema-analysis.ts b/bridge/src/ai/prompts/schema-analysis.ts index a530893..8d9a79d 100644 --- a/bridge/src/ai/prompts/schema-analysis.ts +++ b/bridge/src/ai/prompts/schema-analysis.ts @@ -13,25 +13,22 @@ export function buildSchemaAnalysisPrompt(input: SchemaAnalysisInput): { 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(", ") + "]" : ""}`; + if (c.references) flags.push(`→${c.references.table}.${c.references.column}`); + return `${c.name}(${c.type})${flags.length ? "[" + flags.join(",") + "]" : ""}`; }) - .join("\n"); + .join(", "); const extras: string[] = []; - if (t.indexes?.length) extras.push(`Indexes: ${t.indexes.join(", ")}`); - if (t.foreignKeys?.length) extras.push(`Foreign keys: ${t.foreignKeys.join(", ")}`); - if (t.constraints?.length) extras.push(`Constraints: ${t.constraints.join(", ")}`); - - return [ - `### Table: ${t.schema ? `${t.schema}.` : ""}${t.name}`, - columns, - extras.length ? extras.map((e) => ` * ${e}`).join("\n") : "", - ] - .filter(Boolean) - .join("\n"); + 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\n"); + .join("\n"); const dbType = input.databaseType ? ` (${input.databaseType})` : ""; diff --git a/bridge/src/ai/providers/anthropic.provider.ts b/bridge/src/ai/providers/anthropic.provider.ts index a366468..dac87c9 100644 --- a/bridge/src/ai/providers/anthropic.provider.ts +++ b/bridge/src/ai/providers/anthropic.provider.ts @@ -23,7 +23,7 @@ export class AnthropicProvider implements AIProvider { try { const msg = await this.client.messages.create({ model: DEFAULT_MODEL, - max_tokens: 2048, + max_tokens: 4096, system, messages: [{ role: "user", content: user }], }); diff --git a/bridge/src/ai/providers/gemini.provider.ts b/bridge/src/ai/providers/gemini.provider.ts index a5f37c9..8de2bc8 100644 --- a/bridge/src/ai/providers/gemini.provider.ts +++ b/bridge/src/ai/providers/gemini.provider.ts @@ -24,6 +24,7 @@ export class GeminiProvider implements AIProvider { const model = this.genAI.getGenerativeModel({ model: DEFAULT_MODEL, systemInstruction: system, + generationConfig: { maxOutputTokens: 4096 }, }); const result = await model.generateContent(user); return result.response.text(); diff --git a/bridge/src/ai/providers/groq.provider.ts b/bridge/src/ai/providers/groq.provider.ts index f433e40..4b840d7 100644 --- a/bridge/src/ai/providers/groq.provider.ts +++ b/bridge/src/ai/providers/groq.provider.ts @@ -27,7 +27,7 @@ export class GroqProvider implements AIProvider { { role: "system", content: system }, { role: "user", content: user }, ], - max_tokens: 2048, + max_tokens: 4096, }); return res.choices[0]?.message?.content ?? ""; } catch (err) { diff --git a/bridge/src/ai/providers/mistral.provider.ts b/bridge/src/ai/providers/mistral.provider.ts index 2c6d1c7..abb74ee 100644 --- a/bridge/src/ai/providers/mistral.provider.ts +++ b/bridge/src/ai/providers/mistral.provider.ts @@ -27,7 +27,7 @@ export class MistralProvider implements AIProvider { { role: "system", content: system }, { role: "user", content: user }, ], - maxTokens: 2048, + maxTokens: 4096, }); const choice = res.choices?.[0]; const content = choice?.message?.content; diff --git a/bridge/src/ai/providers/ollama.provider.ts b/bridge/src/ai/providers/ollama.provider.ts index b109927..5452c04 100644 --- a/bridge/src/ai/providers/ollama.provider.ts +++ b/bridge/src/ai/providers/ollama.provider.ts @@ -29,6 +29,7 @@ export class OllamaProvider implements AIProvider { { role: "system", content: system }, { role: "user", content: user }, ], + options: { num_predict: 4096 }, }); return res.message?.content ?? ""; } catch (err) { diff --git a/bridge/src/ai/providers/openai.provider.ts b/bridge/src/ai/providers/openai.provider.ts index c09da72..1c9e841 100644 --- a/bridge/src/ai/providers/openai.provider.ts +++ b/bridge/src/ai/providers/openai.provider.ts @@ -27,7 +27,7 @@ export class OpenAIProvider implements AIProvider { { role: "system", content: system }, { role: "user", content: user }, ], - max_tokens: 2048, + max_tokens: 4096, }); return res.choices[0]?.message?.content ?? ""; } catch (err) { From ffdbaa284c73a1c936d4957133d4b3209e14d314 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 8 Jun 2026 20:28:59 +0530 Subject: [PATCH 03/17] feat: export resolvePkgNativeBindingPath function and update AIHistoryStore to use it --- bridge/src/connectors/sqlite.ts | 6 ++++-- bridge/src/services/aiHistoryStore.ts | 8 +++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bridge/src/connectors/sqlite.ts b/bridge/src/connectors/sqlite.ts index 0815fb0..12f6d3a 100644 --- a/bridge/src/connectors/sqlite.ts +++ b/bridge/src/connectors/sqlite.ts @@ -322,7 +322,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 +376,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`); diff --git a/bridge/src/services/aiHistoryStore.ts b/bridge/src/services/aiHistoryStore.ts index 28e7780..726f8c1 100644 --- a/bridge/src/services/aiHistoryStore.ts +++ b/bridge/src/services/aiHistoryStore.ts @@ -7,6 +7,7 @@ import Database from "better-sqlite3"; import path from "path"; +import { resolvePkgNativeBindingPath } from "../connectors/sqlite"; import fs from "fs"; import { CONFIG_FOLDER } from "../utils/config"; @@ -108,12 +109,9 @@ export class AIHistoryStore { // Resolve native binding for pkg builds (reuse existing pattern) let nativeBinding: string | undefined; try { - const { resolvePkgNativeBindingPath } = require("../connectors/sqlite"); - if (typeof resolvePkgNativeBindingPath === "function") { - nativeBinding = resolvePkgNativeBindingPath() ?? undefined; - } + nativeBinding = resolvePkgNativeBindingPath() ?? undefined; } catch { - // Not in a pkg environment or function not exported; ignore. + // Ignore if it fails for any reason } const opts: Database.Options = {}; From 1321b753625868d855206017d95a68837e25d1e8 Mon Sep 17 00:00:00 2001 From: Yash Date: Tue, 9 Jun 2026 13:49:45 +0530 Subject: [PATCH 04/17] docs: update CONTRIBUTING.md with build instructions for bridge package and FEATURES.md with AI Providers feature --- CONTRIBUTING.md | 14 ++++++++++++++ FEATURES.md | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 93f291d..7e6f9c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,20 @@ 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 diff --git a/FEATURES.md b/FEATURES.md index af6e460..b2d0d19 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -376,6 +376,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 From f2daab7d6da1769fc138258deac87dddbe79a9c6 Mon Sep 17 00:00:00 2001 From: Yash Date: Fri, 12 Jun 2026 12:55:16 +0530 Subject: [PATCH 05/17] feat(migration): enhance migration handling with sync and lock features --- bridge/src/connectors/mariadb.ts | 26 ++- bridge/src/connectors/mysql.ts | 26 ++- bridge/src/connectors/postgres.ts | 26 ++- bridge/src/connectors/sqlite.ts | 43 +++-- bridge/src/handlers/migrationHandlers.ts | 183 ++++++++++++++++++- bridge/src/handlers/projectHandlers.ts | 151 +++++++++++++++- bridge/src/jsonRpcHandler.ts | 26 ++- bridge/src/queries/mysql/columns.ts | 6 +- bridge/src/queries/postgres/tables.ts | 25 ++- bridge/src/services/gitService.ts | 216 +++++++++++++++++++++++ bridge/src/services/migrationLock.ts | 50 ++++++ bridge/src/services/projectStore.ts | 143 ++++++++++++++- bridge/src/services/queryExecutor.ts | 7 +- bridge/src/types/common.ts | 4 + bridge/src/utils/baselineMigration.ts | 98 +++++++++- 15 files changed, 988 insertions(+), 42 deletions(-) create mode 100644 bridge/src/services/migrationLock.ts diff --git a/bridge/src/connectors/mariadb.ts b/bridge/src/connectors/mariadb.ts index 8ce9151..70bf1c6 100644 --- a/bridge/src/connectors/mariadb.ts +++ b/bridge/src/connectors/mariadb.ts @@ -34,6 +34,7 @@ import { MySQLAlterTableOperation as MariaDBAlterTableOperation, MySQLDropMode as MariaDBDropMode, } from "../types/mysql"; +import { SchemaFile } from "../services/projectStore"; export type { ColumnDetail, @@ -916,7 +917,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 +1355,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 +1369,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 diff --git a/bridge/src/connectors/mysql.ts b/bridge/src/connectors/mysql.ts index 72acba9..878a4db 100644 --- a/bridge/src/connectors/mysql.ts +++ b/bridge/src/connectors/mysql.ts @@ -34,6 +34,7 @@ import { MySQLAlterTableOperation, MySQLDropMode, } from "../types/mysql"; +import { SchemaFile } from "../services/projectStore"; // Re-export types for backward compatibility export type { @@ -892,7 +893,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 }); } @@ -1324,10 +1330,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 +1344,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 diff --git a/bridge/src/connectors/postgres.ts b/bridge/src/connectors/postgres.ts index 19084dc..b216b38 100644 --- a/bridge/src/connectors/postgres.ts +++ b/bridge/src/connectors/postgres.ts @@ -32,6 +32,7 @@ import { PGAlterTableOperation, PGDropMode, } from "../types/postgres"; +import { SchemaFile } from "../services/projectStore"; export type { PGConfig, @@ -801,7 +802,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 }); } @@ -1409,10 +1415,10 @@ export async function insertBaseline( } } - export async function baselineIfNeeded( conn: PGConfig, - migrationsDir: string + migrationsDir: string, + snapshot?: SchemaFile ) { const client = createClient(conn); @@ -1426,10 +1432,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 diff --git a/bridge/src/connectors/sqlite.ts b/bridge/src/connectors/sqlite.ts index 12f6d3a..f718408 100644 --- a/bridge/src/connectors/sqlite.ts +++ b/bridge/src/connectors/sqlite.ts @@ -55,6 +55,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 @@ -734,6 +735,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: null, + comment: null, + ordinal_position: col.cid + 1, })); const primaryKeys: PrimaryKeyInfo[] = cols @@ -771,14 +777,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, + }); + } } } } @@ -1099,7 +1110,8 @@ export async function insertBaseline( /** Baseline if needed */ export async function baselineIfNeeded( cfg: SQLiteConfig, - migrationsDir: string + migrationsDir: string, + snapshot?: SchemaFile ) { await ensureMigrationTable(cfg); @@ -1109,7 +1121,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") diff --git a/bridge/src/handlers/migrationHandlers.ts b/bridge/src/handlers/migrationHandlers.ts index f4cefd8..1121506 100644 --- a/bridge/src/handlers/migrationHandlers.ts +++ b/bridge/src/handlers/migrationHandlers.ts @@ -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) { + 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"); + } + } + + // Update Migration Lock + 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); + writeMigrationLock(dbId, schemaHash, appliedVersions); + } + } 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,112 @@ export class MigrationHandlers { } } + async handleApplyMigrations(params: any, id: number | string) { + try { + const { dbId } = params || {}; + if (!dbId) return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing dbId" }); + + const { conn, dbType } = await this.dbService.getDatabaseConnection(dbId); + const migrationsDir = getMigrationsDir(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); + 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 { + const { dbId } = params || {}; + if (!dbId) return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing dbId" }); + + const { conn, dbType } = await this.dbService.getDatabaseConnection(dbId); + + const { projectStoreInstance } = await import("../services/projectStore"); + 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 tmpPath = path.join(getMigrationsDir(dbId), "tmp_snapshot_apply.sql"); + fs.writeFileSync(tmpPath, baselineSQL, "utf8"); + + if (dbType === "mysql") await this.queryExecutor.mysql.applyMigration(conn, tmpPath); + else if (dbType === "postgres") await this.queryExecutor.postgres.applyMigration(conn, tmpPath); + else if (dbType === "mariadb") await this.queryExecutor.mariadb.applyMigration(conn, tmpPath); + else if (dbType === "sqlite") await this.queryExecutor.sqlite.applyMigration(conn, tmpPath); + + if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); + + try { + const { writeMigrationLock } = await import("../services/migrationLock"); + const schemaHash = (snapshot as any)?.schemaHash || ""; + const updatedApplied = await connector.listAppliedMigrations(conn); + 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 || {}; @@ -217,6 +360,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) { + 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"); + } + } + + // Update Migration Lock + 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); + writeMigrationLock(dbId, schemaHash, appliedVersions); + } + } 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"); diff --git a/bridge/src/handlers/projectHandlers.ts b/bridge/src/handlers/projectHandlers.ts index d9f3f22..e802429 100644 --- a/bridge/src/handlers/projectHandlers.ts +++ b/bridge/src/handlers/projectHandlers.ts @@ -1,6 +1,9 @@ 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"; /** * RPC handlers for project CRUD and sub-resource operations. @@ -9,7 +12,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 +175,64 @@ 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 { + await gitServiceInstance.syncSchemaFile(projectId); + } 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 +310,90 @@ 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 result = await projectStoreInstance.analyzeImportedProject(projectId); + 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 = 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.stageAll(projectDir); + await gitServiceInstance.commit(projectDir, "Push migrations"); + // Here you'd call git push if configured, for now just returning ok + 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 +577,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 { diff --git a/bridge/src/jsonRpcHandler.ts b/bridge/src/jsonRpcHandler.ts index 55babca..7b515a6 100644 --- a/bridge/src/jsonRpcHandler.ts +++ b/bridge/src/jsonRpcHandler.ts @@ -65,7 +65,7 @@ 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); @@ -168,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) ); @@ -227,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) ); @@ -239,6 +248,21 @@ 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.getDrift", (p, id) => + projectHandlers.handleGetDrift(p, id) + ); rpcRegister(rpc, "project.getQueries", (p, id) => projectHandlers.handleGetQueries(p, id) ); 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/gitService.ts b/bridge/src/services/gitService.ts index f96c04e..9337866 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", "migration-lock.json"]; + 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..8e1f21c --- /dev/null +++ b/bridge/src/services/migrationLock.ts @@ -0,0 +1,50 @@ +import fs from "fs"; +import path from "path"; +import crypto from "crypto"; +import { getMigrationsDir } from "../utils/config"; + +export interface MigrationLock { + version: string; + schemaHash: string; + appliedMigrations: string[]; // List of migration filenames + updatedAt: string; +} + +export function getLockFilePath(dbId: string): string { + return path.join(getMigrationsDir(dbId), "migration.lock.json"); +} + +export function readMigrationLock(dbId: string): MigrationLock | null { + const lockPath = getLockFilePath(dbId); + if (!fs.existsSync(lockPath)) return null; + + try { + const raw = fs.readFileSync(lockPath, "utf8"); + return JSON.parse(raw) as MigrationLock; + } catch (err) { + return null; + } +} + +export function writeMigrationLock( + dbId: string, + schemaHash: string, + appliedMigrations: string[] +): void { + const lockPath = getLockFilePath(dbId); + const lockData: MigrationLock = { + version: "1", + schemaHash, + appliedMigrations, + updatedAt: new Date().toISOString() + }; + + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync(lockPath, JSON.stringify(lockData, null, 2), "utf8"); +} + +export function verifyMigrationLock(dbId: string, targetHash: string): boolean { + const lock = 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..73b2001 100644 --- a/bridge/src/services/projectStore.ts +++ b/bridge/src/services/projectStore.ts @@ -74,22 +74,31 @@ export type AnnotationsFile = { }; export type SchemaFile = { - version: number; + version: number; // bumped to 2 projectId: string; databaseId: string; + dialect: "postgresql" | "mysql" | "sqlite"; 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,6 +109,49 @@ 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< @@ -371,11 +423,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, @@ -572,6 +627,33 @@ export class ProjectStore { * For imported projects the source directory is NOT deleted * (the user owns it) — only the index entry is removed. */ + 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 = 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); @@ -592,33 +674,80 @@ export class ProjectStore { } 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); + } + + 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[]): Promise { + 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( 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/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/utils/baselineMigration.ts b/bridge/src/utils/baselineMigration.ts index 03d3867..68a0089 100644 --- a/bridge/src/utils/baselineMigration.ts +++ b/bridge/src/utils/baselineMigration.ts @@ -1,14 +1,103 @@ 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) { + const tableRef = dbType === "sqlite" ? quoteIdent(table.name, dbType) : `${quoteIdent(schema.name, dbType)}.${quoteIdent(table.name, dbType)}`; + + 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 +107,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 +117,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; From 9f47b981ab61214a604ba18c0a61eaf71eeafb98 Mon Sep 17 00:00:00 2001 From: Yash Date: Fri, 12 Jun 2026 18:22:34 +0530 Subject: [PATCH 06/17] feat(migrations): refactor migration directory handling and enhance migration lock functionality --- bridge/src/connectors/mariadb.ts | 5 +- bridge/src/connectors/mysql.ts | 5 +- bridge/src/connectors/postgres.ts | 5 +- bridge/src/connectors/sqlite.ts | 5 +- bridge/src/handlers/migrationHandlers.ts | 77 +++++++---- bridge/src/handlers/projectHandlers.ts | 163 ++++++++++++++++++++++- bridge/src/services/migrationLock.ts | 33 ++--- bridge/src/services/projectStore.ts | 35 ++++- bridge/src/utils/config.ts | 4 - bridge/src/utils/migrationFileReader.ts | 4 +- 10 files changed, 274 insertions(+), 62 deletions(-) diff --git a/bridge/src/connectors/mariadb.ts b/bridge/src/connectors/mariadb.ts index 70bf1c6..aec10d4 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, @@ -1438,7 +1439,7 @@ 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) { diff --git a/bridge/src/connectors/mysql.ts b/bridge/src/connectors/mysql.ts index 878a4db..c79a950 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, @@ -1413,7 +1414,7 @@ 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) { diff --git a/bridge/src/connectors/postgres.ts b/bridge/src/connectors/postgres.ts index b216b38..d09fc71 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, @@ -1503,7 +1504,7 @@ 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) { diff --git a/bridge/src/connectors/sqlite.ts b/bridge/src/connectors/sqlite.ts index f718408..ac75c5f 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, @@ -1167,7 +1168,7 @@ 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) { diff --git a/bridge/src/handlers/migrationHandlers.ts b/bridge/src/handlers/migrationHandlers.ts index 1121506..5adc506 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'); @@ -200,9 +200,9 @@ export class MigrationHandlers { const schemaFile = await projectStoreInstance.getSchema(project.id); const schemaHash = (schemaFile as any)?.schemaHash || ""; - + const appliedVersions = appliedMigrations.map(m => m.version); - writeMigrationLock(dbId, schemaHash, appliedVersions); + await writeMigrationLock(dbId, schemaHash, appliedVersions); } } catch (syncErr) { this.logger?.error({ err: syncErr }, "syncMigrationFiles/lock hook failed"); @@ -217,30 +217,39 @@ export class MigrationHandlers { async handleApplyMigrations(params: any, id: number | string) { try { - const { dbId } = params || {}; - if (!dbId) return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing dbId" }); + 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 = getMigrationsDir(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); @@ -256,7 +265,7 @@ export class MigrationHandlers { const schemaFile = await projectStoreInstance.getSchema(project.id); const schemaHash = (schemaFile as any)?.schemaHash || ""; const updatedApplied = await connector.listAppliedMigrations(conn); - writeMigrationLock(dbId, schemaHash, updatedApplied.map((m: any) => m.version)); + await writeMigrationLock(dbId, schemaHash, updatedApplied.map((m: any) => m.version)); } } catch (syncErr) { this.logger?.error({ err: syncErr }, "Lock hook failed"); @@ -271,12 +280,21 @@ export class MigrationHandlers { async handleApplySnapshot(params: any, id: number | string) { try { - const { dbId } = params || {}; - if (!dbId) return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing dbId" }); + 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 { projectStoreInstance } = await import("../services/projectStore"); const project = await projectStoreInstance.getProjectByDatabaseId(dbId); if (!project) throw new Error("Project not found for database"); const snapshot = await projectStoreInstance.getSchema(project.id); @@ -295,21 +313,22 @@ export class MigrationHandlers { // A full implementation would drop tables here. // For now, we write the SQL and apply it. - const tmpPath = path.join(getMigrationsDir(dbId), "tmp_snapshot_apply.sql"); + const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId); + const tmpPath = path.join(migrationsDir, "tmp_snapshot_apply.sql"); fs.writeFileSync(tmpPath, baselineSQL, "utf8"); - + if (dbType === "mysql") await this.queryExecutor.mysql.applyMigration(conn, tmpPath); else if (dbType === "postgres") await this.queryExecutor.postgres.applyMigration(conn, tmpPath); else if (dbType === "mariadb") await this.queryExecutor.mariadb.applyMigration(conn, tmpPath); else if (dbType === "sqlite") await this.queryExecutor.sqlite.applyMigration(conn, tmpPath); - + if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); try { const { writeMigrationLock } = await import("../services/migrationLock"); const schemaHash = (snapshot as any)?.schemaHash || ""; const updatedApplied = await connector.listAppliedMigrations(conn); - writeMigrationLock(dbId, schemaHash, updatedApplied.map((m: any) => m.version)); + await writeMigrationLock(dbId, schemaHash, updatedApplied.map((m: any) => m.version)); } catch (syncErr) { this.logger?.error({ err: syncErr }, "Lock hook failed"); } @@ -333,7 +352,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'); @@ -390,9 +409,9 @@ export class MigrationHandlers { const schemaFile = await projectStoreInstance.getSchema(project.id); const schemaHash = (schemaFile as any)?.schemaHash || ""; - + const appliedVersions = appliedMigrations.map(m => m.version); - writeMigrationLock(dbId, schemaHash, appliedVersions); + await writeMigrationLock(dbId, schemaHash, appliedVersions); } } catch (syncErr) { this.logger?.error({ err: syncErr }, "syncMigrationFiles/lock hook failed"); @@ -416,7 +435,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'); @@ -451,7 +470,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 e802429..fa1371d 100644 --- a/bridge/src/handlers/projectHandlers.ts +++ b/bridge/src/handlers/projectHandlers.ts @@ -4,6 +4,7 @@ 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. @@ -316,7 +317,165 @@ export class ProjectHandlers { if (!projectId) { return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" }); } - const result = await projectStoreInstance.analyzeImportedProject(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. 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"; + } + + // 7. Compute available modes + const availableModes: Array<"run_migrations" | "apply_snapshot" | "skip"> = ["skip"]; + let recommendedMode: "run_migrations" | "apply_snapshot" | "skip" = "skip"; + if (hasMigrations && pendingMigrations.length > 0) { + availableModes.unshift("run_migrations"); + recommendedMode = "run_migrations"; + } + if (hasSchemaSnapshot && targetDatabaseEmpty && !hasMigrations) { + availableModes.unshift("apply_snapshot"); + recommendedMode = "apply_snapshot"; + } + + const result = { + hasMigrations, + migrationCount: pendingMigrations.length, + hasSchemaSnapshot, + lockFileStatus, + tamperedFiles, + targetDatabaseEmpty, + targetTableCount, + driftStatus, + driftDetails: undefined, + pendingMigrations, + availableModes, + recommendedMode, + }; + this.rpc.sendResponse(id, { ok: true, data: result }); } catch (e: any) { this.logger?.error({ e }, "project.analyzeImport failed"); @@ -338,7 +497,7 @@ export class ProjectHandlers { const schemaFile = await projectStoreInstance.getSchema(projectId); const schemaHash = (schemaFile as any)?.schemaHash || ""; - const isValid = verifyMigrationLock(project.databaseId, 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"); diff --git a/bridge/src/services/migrationLock.ts b/bridge/src/services/migrationLock.ts index 8e1f21c..efa358b 100644 --- a/bridge/src/services/migrationLock.ts +++ b/bridge/src/services/migrationLock.ts @@ -1,7 +1,7 @@ -import fs from "fs"; +import fs from "fs/promises"; import path from "path"; -import crypto from "crypto"; -import { getMigrationsDir } from "../utils/config"; +import fsSync from "fs"; +import { projectStoreInstance } from "./projectStore"; export interface MigrationLock { version: string; @@ -10,28 +10,29 @@ export interface MigrationLock { updatedAt: string; } -export function getLockFilePath(dbId: string): string { - return path.join(getMigrationsDir(dbId), "migration.lock.json"); +export async function getLockFilePath(dbId: string): Promise { + const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId); + return path.join(migrationsDir, "migration.lock.json"); } -export function readMigrationLock(dbId: string): MigrationLock | null { - const lockPath = getLockFilePath(dbId); - if (!fs.existsSync(lockPath)) return null; +export async function readMigrationLock(dbId: string): Promise { + const lockPath = await getLockFilePath(dbId); + if (!fsSync.existsSync(lockPath)) return null; try { - const raw = fs.readFileSync(lockPath, "utf8"); + const raw = await fs.readFile(lockPath, "utf8"); return JSON.parse(raw) as MigrationLock; } catch (err) { return null; } } -export function writeMigrationLock( +export async function writeMigrationLock( dbId: string, schemaHash: string, appliedMigrations: string[] -): void { - const lockPath = getLockFilePath(dbId); +): Promise { + const lockPath = await getLockFilePath(dbId); const lockData: MigrationLock = { version: "1", schemaHash, @@ -39,12 +40,12 @@ export function writeMigrationLock( updatedAt: new Date().toISOString() }; - fs.mkdirSync(path.dirname(lockPath), { recursive: true }); - fs.writeFileSync(lockPath, JSON.stringify(lockData, null, 2), "utf8"); + await fs.mkdir(path.dirname(lockPath), { recursive: true }).catch(() => {}); + await fs.writeFile(lockPath, JSON.stringify(lockData, null, 2), "utf8"); } -export function verifyMigrationLock(dbId: string, targetHash: string): boolean { - const lock = readMigrationLock(dbId); +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 73b2001..111b5af 100644 --- a/bridge/src/services/projectStore.ts +++ b/bridge/src/services/projectStore.ts @@ -627,6 +627,39 @@ export class ProjectStore { * 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 || ""; @@ -640,7 +673,7 @@ export class ProjectStore { const project = await this.getProject(projectId); if (project?.databaseId) { const { readMigrationLock } = await import("./migrationLock"); - const lock = readMigrationLock(project.databaseId); + const lock = await readMigrationLock(project.databaseId); if (lock) { hasLock = true; hashMatch = lock.schemaHash === schemaHash; 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}`); } From 45106869a977585cf1af9623795c96479a08782d Mon Sep 17 00:00:00 2001 From: Yash Date: Fri, 12 Jun 2026 18:45:25 +0530 Subject: [PATCH 07/17] feat: update uuid package to version 14.0.0 and add types for uuid; enhance migration handling with schema snapshots --- bridge/package.json | 3 ++- bridge/pnpm-lock.yaml | 21 ++++++++++++++-- bridge/src/connectors/mariadb.ts | 12 +++++++-- bridge/src/connectors/mysql.ts | 14 ++++++++--- bridge/src/connectors/postgres.ts | 12 +++++++-- bridge/src/connectors/sqlite.ts | 14 ++++++++--- bridge/src/handlers/migrationHandlers.ts | 3 ++- bridge/src/handlers/projectHandlers.ts | 31 ++++++++++++++++++------ bridge/src/services/projectStore.ts | 8 +++--- bridge/src/utils/baselineMigration.ts | 21 ++++++++++++++++ 10 files changed, 114 insertions(+), 25 deletions(-) diff --git a/bridge/package.json b/bridge/package.json index 9e1b42c..1f4c107 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -33,7 +33,7 @@ "pg-query-stream": "^4.10.3", "pino": "^9.14.0", "ssh2": "^1.17.0", - "uuid": "^8.3.2", + "uuid": "^14.0.0", "ws": "^8.19.0" }, "bin": "./dist/index.cjs", @@ -52,6 +52,7 @@ "@types/jest": "^30.0.0", "@types/node": "^20.0.0", "@types/pg": "^8.15.6", + "@types/uuid": "^11.0.0", "@yao-pkg/pkg": "^5.12.0", "esbuild": "^0.27.0", "jest": "^30.2.0", diff --git a/bridge/pnpm-lock.yaml b/bridge/pnpm-lock.yaml index f00a3ba..b7c4b25 100644 --- a/bridge/pnpm-lock.yaml +++ b/bridge/pnpm-lock.yaml @@ -60,8 +60,8 @@ importers: specifier: ^1.17.0 version: 1.17.0 uuid: - specifier: ^8.3.2 - version: 8.3.2 + specifier: ^14.0.0 + version: 14.0.0 ws: specifier: ^8.19.0 version: 8.20.1 @@ -78,6 +78,9 @@ importers: '@types/pg': specifier: ^8.15.6 version: 8.20.0 + '@types/uuid': + specifier: ^11.0.0 + version: 11.0.0 '@yao-pkg/pkg': specifier: ^5.12.0 version: 5.16.1 @@ -734,6 +737,10 @@ packages: '@types/strip-json-comments@0.0.30': resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==} + '@types/uuid@11.0.0': + resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==} + deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed. + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -2201,6 +2208,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@14.0.0: + resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} 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). @@ -2988,6 +2999,10 @@ snapshots: '@types/strip-json-comments@0.0.30': {} + '@types/uuid@11.0.0': + dependencies: + uuid: 8.3.2 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -4603,6 +4618,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@14.0.0: {} + uuid@8.3.2: {} v8-compile-cache-lib@3.0.1: {} diff --git a/bridge/src/connectors/mariadb.ts b/bridge/src/connectors/mariadb.ts index aec10d4..6c77d95 100644 --- a/bridge/src/connectors/mariadb.ts +++ b/bridge/src/connectors/mariadb.ts @@ -550,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); @@ -1443,7 +1443,15 @@ export async function connectToDatabase( 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 c79a950..dd49a42 100644 --- a/bridge/src/connectors/mysql.ts +++ b/bridge/src/connectors/mysql.ts @@ -525,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); @@ -1110,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)) ); } @@ -1418,7 +1418,15 @@ export async function connectToDatabase( 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 d09fc71..4f56d94 100644 --- a/bridge/src/connectors/postgres.ts +++ b/bridge/src/connectors/postgres.ts @@ -1176,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)) ); } @@ -1508,7 +1508,15 @@ export async function connectToDatabase( 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 ac75c5f..38b56e4 100644 --- a/bridge/src/connectors/sqlite.ts +++ b/bridge/src/connectors/sqlite.ts @@ -738,8 +738,8 @@ export async function getSchemaMetadataBatch( 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: null, - comment: null, + check_constraint: undefined, + comment: undefined, ordinal_position: col.cid + 1, })); @@ -1172,7 +1172,15 @@ export async function connectToDatabase( 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/migrationHandlers.ts b/bridge/src/handlers/migrationHandlers.ts index 5adc506..7924fa6 100644 --- a/bridge/src/handlers/migrationHandlers.ts +++ b/bridge/src/handlers/migrationHandlers.ts @@ -314,7 +314,8 @@ export class MigrationHandlers { // For now, we write the SQL and apply it. const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId); - const tmpPath = path.join(migrationsDir, "tmp_snapshot_apply.sql"); + const versionStr = Date.now().toString(); + const tmpPath = path.join(migrationsDir, `${versionStr}_tmp_snapshot_apply.sql`); fs.writeFileSync(tmpPath, baselineSQL, "utf8"); if (dbType === "mysql") await this.queryExecutor.mysql.applyMigration(conn, tmpPath); diff --git a/bridge/src/handlers/projectHandlers.ts b/bridge/src/handlers/projectHandlers.ts index fa1371d..8eb9bfe 100644 --- a/bridge/src/handlers/projectHandlers.ts +++ b/bridge/src/handlers/projectHandlers.ts @@ -217,7 +217,7 @@ export class ProjectHandlers { await projectStoreInstance.saveSchema(projectId, liveSchemas, newHash, dbType); if (newHash !== oldHash) { - this.rpc.sendNotification("project.schema_changed", { projectId, newHash }); + this.rpc?.sendNotification?.("project.schema_changed", { projectId, newHash }); // Commit to Git if tracking try { @@ -431,7 +431,10 @@ export class ProjectHandlers { pendingMigrations.push({ file, version, isDestructive, destructiveOps }); } - // 6. Compute drift status + // 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 @@ -449,10 +452,18 @@ export class ProjectHandlers { driftStatus = "synced"; } - // 7. Compute available modes + // 8. Compute available modes const availableModes: Array<"run_migrations" | "apply_snapshot" | "skip"> = ["skip"]; let recommendedMode: "run_migrations" | "apply_snapshot" | "skip" = "skip"; - if (hasMigrations && pendingMigrations.length > 0) { + + 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"; } @@ -461,9 +472,15 @@ export class ProjectHandlers { 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, - migrationCount: pendingMigrations.length, + hasMigrations: reportHasMigrations, + migrationCount: reportPendingMigrations.length, hasSchemaSnapshot, lockFileStatus, tamperedFiles, @@ -471,7 +488,7 @@ export class ProjectHandlers { targetTableCount, driftStatus, driftDetails: undefined, - pendingMigrations, + pendingMigrations: reportPendingMigrations, availableModes, recommendedMode, }; diff --git a/bridge/src/services/projectStore.ts b/bridge/src/services/projectStore.ts index 111b5af..3ed948d 100644 --- a/bridge/src/services/projectStore.ts +++ b/bridge/src/services/projectStore.ts @@ -77,7 +77,7 @@ export type SchemaFile = { version: number; // bumped to 2 projectId: string; databaseId: string; - dialect: "postgresql" | "mysql" | "sqlite"; + dialect: "postgresql" | "mysql" | "sqlite" | "unknown"; schemas: SchemaSnapshot[]; cachedAt: string; relwaveVersion: string; @@ -391,7 +391,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 @@ -564,7 +564,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 @@ -1384,7 +1384,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/utils/baselineMigration.ts b/bridge/src/utils/baselineMigration.ts index 68a0089..291363e 100644 --- a/bridge/src/utils/baselineMigration.ts +++ b/bridge/src/utils/baselineMigration.ts @@ -41,8 +41,29 @@ export function generateBaselineSQL(snapshot: SchemaFile, version: string, name: // 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"; From ae7c51e572295f9e8c26754fcf6cf12e29b2e0f5 Mon Sep 17 00:00:00 2001 From: Yash Date: Fri, 12 Jun 2026 18:48:35 +0530 Subject: [PATCH 08/17] feat: implement migration analysis and sync features; add SQL preview and schema drift handling components --- src/components/ui/SQLPreviewSheet.tsx | 72 +++++ src/components/ui/sheet.tsx | 141 +++++++++ src/components/ui/skeleton.tsx | 13 + .../home/components/MigrationStatusCard.tsx | 103 +++++++ .../components/MigrationSyncDialog.tsx | 290 ++++++++++++++++++ .../project/components/SchemaDriftSheet.tsx | 151 +++++++++ .../project/hooks/useImportAnalysis.ts | 38 +++ src/features/project/hooks/useProjectSync.ts | 55 +++- src/features/project/types.ts | 47 +++ src/services/bridge/project.ts | 125 ++++++++ 10 files changed, 1032 insertions(+), 3 deletions(-) create mode 100644 src/components/ui/SQLPreviewSheet.tsx create mode 100644 src/components/ui/sheet.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/features/home/components/MigrationStatusCard.tsx create mode 100644 src/features/project/components/MigrationSyncDialog.tsx create mode 100644 src/features/project/components/SchemaDriftSheet.tsx create mode 100644 src/features/project/hooks/useImportAnalysis.ts 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. + +
+
+ + + + +
+
+
+ +
+                {sql || "No SQL available."}
+              
+
+
+
+
+
+ ); +} 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/home/components/MigrationStatusCard.tsx b/src/features/home/components/MigrationStatusCard.tsx new file mode 100644 index 0000000..8b2ec6a --- /dev/null +++ b/src/features/home/components/MigrationStatusCard.tsx @@ -0,0 +1,103 @@ +import { useEffect, 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 { projectService } from "@/services/bridge/project"; +import { AlertTriangle, Database, RefreshCw, CheckCircle2 } from "lucide-react"; +import { SchemaDriftSheet } from "@/features/project/components/SchemaDriftSheet"; +import { Skeleton } from "@/components/ui/skeleton"; + +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); + + useEffect(() => { + // Optionally poll or just rely on manual refresh + }, [projectId]); + + 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 + + ) : ( + + {analysis.migrationCount} Pending + + )} +
+ + State of the live database against the project schema. + +
+ +
+
+ Pending Migrations + {analysis.migrationCount} +
+ + {isDrifted && analysis.driftDetails && ( +
+ Schema drift detected. The live database has modifications not tracked in the schema snapshot. +
+ )} + +
+ + {isDrifted && ( + + )} +
+
+
+
+ + {analysis.driftDetails && ( + + )} + + ); +} 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. + + + + + + + + ); + } + + // 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... +
+ ) : ( + <> + + + + )} +
+
+
+ ); + } + + // 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... +
+ ) : ( + <> +
+ +
+ + + + )} +
+
+ + { if (!val) setPreviewSql(null); }} + sql={previewSql || ""} + title="Baseline SQL Preview" + /> +
+ ); + } + + return null; +} diff --git a/src/features/project/components/SchemaDriftSheet.tsx b/src/features/project/components/SchemaDriftSheet.tsx new file mode 100644 index 0000000..b15560d --- /dev/null +++ b/src/features/project/components/SchemaDriftSheet.tsx @@ -0,0 +1,151 @@ +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); + } + }; + + if (!driftDetails) return null; + + const hasAdded = driftDetails.tablesAdded.length > 0; + const hasRemoved = driftDetails.tablesRemoved.length > 0; + const hasModified = driftDetails.tablesModified.length > 0; + + return ( + + + + Schema Drift — {connectionName} + + Live database differs from schema.json{capturedDate ? ` captured ${new Date(capturedDate).toLocaleString()}` : ""}. + + + + +
+ {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(", ")}
+ )} +
+ ))} +
+
+
+ )} +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/features/project/hooks/useImportAnalysis.ts b/src/features/project/hooks/useImportAnalysis.ts new file mode 100644 index 0000000..55f64b2 --- /dev/null +++ b/src/features/project/hooks/useImportAnalysis.ts @@ -0,0 +1,38 @@ +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/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/services/bridge/project.ts b/src/services/bridge/project.ts index e80014c..950a48a 100644 --- a/src/services/bridge/project.ts +++ b/src/services/bridge/project.ts @@ -344,6 +344,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 From 6140afd839bb8e1d0d353dbad6170be05d2bae9d Mon Sep 17 00:00:00 2001 From: Yash Date: Fri, 12 Jun 2026 18:49:20 +0530 Subject: [PATCH 09/17] feat: enhance migration handling and UI integration; add migration sync functionality and update related components --- pnpm-lock.yaml | 26 ++++ src/components/layout/VerticalIconBar.tsx | 7 +- .../git/components/GitStatusPanel.tsx | 62 +++++++- .../home/components/DatabaseDetail.tsx | 1 + .../home/components/DatabaseOverviewPanel.tsx | 141 ++++++++++++------ .../components/SchemaExplorerHeader.tsx | 2 +- src/pages/DatabaseDetails.tsx | 29 +++- 7 files changed, 206 insertions(+), 62 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daf8c1a..8d58629 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1356,66 +1356,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1485,24 +1498,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -1571,30 +1588,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.10.1': resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.10.1': resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.10.1': resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.10.1': resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==} @@ -2422,24 +2444,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} diff --git a/src/components/layout/VerticalIconBar.tsx b/src/components/layout/VerticalIconBar.tsx index 2671a09..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 } 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; @@ -36,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/features/git/components/GitStatusPanel.tsx b/src/features/git/components/GitStatusPanel.tsx index ad53494..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 + +
+ + +
+
+ )}
{/* Tabs */} diff --git a/src/features/home/components/DatabaseDetail.tsx b/src/features/home/components/DatabaseDetail.tsx index 4971efe..5b3d699 100644 --- a/src/features/home/components/DatabaseDetail.tsx +++ b/src/features/home/components/DatabaseDetail.tsx @@ -175,6 +175,7 @@ export function DatabaseDetail({
{/* Project Data */} -

Project Data

-
- - - - - - Schema Cache - - - {schemaCount ?? "—"} - - - Cached schemas - - + {projectId && ( + <> +

Project Data

+
+ - - - - - - ER Diagram - - - {hasERLayout ? "Saved" : "—"} - - - Diagram layout - - + + + + + + ER Diagram + + + {hasERLayout ? "Saved" : "—"} + + + Diagram layout + + - - - - - - Saved Queries - - - {queryCount ?? "—"} - - - Stored queries - - -
+ + + + + + 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/schema-explorer/components/SchemaExplorerHeader.tsx b/src/features/schema-explorer/components/SchemaExplorerHeader.tsx index 9146abc..84f884d 100644 --- a/src/features/schema-explorer/components/SchemaExplorerHeader.tsx +++ b/src/features/schema-explorer/components/SchemaExplorerHeader.tsx @@ -31,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 (
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%"> From 7157d41d93dcf5dabc0cdeb6c5e43e1a40323f56 Mon Sep 17 00:00:00 2001 From: Yash Date: Sat, 13 Jun 2026 23:56:49 +0530 Subject: [PATCH 10/17] fix: migration status card and sheet --- .../home/components/MigrationStatusCard.tsx | 47 +++-- .../project/components/SchemaDriftSheet.tsx | 171 ++++++++++-------- .../project/hooks/useImportAnalysis.ts | 1 - 3 files changed, 118 insertions(+), 101 deletions(-) diff --git a/src/features/home/components/MigrationStatusCard.tsx b/src/features/home/components/MigrationStatusCard.tsx index 8b2ec6a..f73bdd6 100644 --- a/src/features/home/components/MigrationStatusCard.tsx +++ b/src/features/home/components/MigrationStatusCard.tsx @@ -1,12 +1,12 @@ -import { useEffect, useState } from "react"; +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 { projectService } from "@/services/bridge/project"; 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; @@ -18,10 +18,6 @@ export function MigrationStatusCard({ projectId, databaseId, connectionName }: M const { analysis, loading, refetch } = useImportAnalysis(projectId, databaseId); const [driftSheetOpen, setDriftSheetOpen] = useState(false); - useEffect(() => { - // Optionally poll or just rely on manual refresh - }, [projectId]); - if (loading && !analysis) { return ; } @@ -66,17 +62,29 @@ export function MigrationStatusCard({ projectId, databaseId, connectionName }: M Pending Migrations {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. +
+ )} +
- {isDrifted && ( diff --git a/src/features/project/hooks/useImportAnalysis.ts b/src/features/project/hooks/useImportAnalysis.ts index 55f64b2..c423770 100644 --- a/src/features/project/hooks/useImportAnalysis.ts +++ b/src/features/project/hooks/useImportAnalysis.ts @@ -9,7 +9,6 @@ export function useImportAnalysis(projectId?: string, targetDatabaseId?: string) const fetchAnalysis = useCallback(async () => { if (!projectId) return; - setLoading(true); setError(null); try { From 29026944e9fe86d383b3d9f803b0654f9076f974 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 14 Jun 2026 00:06:48 +0530 Subject: [PATCH 11/17] feat: added format time functions --- .../database/components/MigrationsPanel.tsx | 14 ++----- .../home/components/ConnectionDetails.tsx | 3 +- .../home/components/DatabaseOverviewPanel.tsx | 2 +- src/features/home/components/WelcomeView.tsx | 3 +- src/features/home/components/index.ts | 1 - src/features/home/utils.ts | 17 --------- src/lib/utils.ts | 37 +++++++++++++++++++ 7 files changed, 45 insertions(+), 32 deletions(-) delete mode 100644 src/features/home/utils.ts diff --git a/src/features/database/components/MigrationsPanel.tsx b/src/features/database/components/MigrationsPanel.tsx index 08bdd13..aea23c7 100644 --- a/src/features/database/components/MigrationsPanel.tsx +++ b/src/features/database/components/MigrationsPanel.tsx @@ -6,6 +6,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { MigrationsData } from "@/features/database/types"; import { useMigrationsPanel } from "../hooks/useMigrationsPanel"; import { cn } from "@/lib/utils"; +import { formatTimestamp } from "@/lib/utils"; interface MigrationsPanelProps { migrations: MigrationsData; @@ -72,7 +73,7 @@ export default function MigrationsPanel({ migrations, baselined, dbId }: Migrati
Pending
- {local.length - applied.length} + {allMigrations.filter(m => m.status === "pending").length}
@@ -183,16 +184,9 @@ function MigrationItem({ migration, onApply, onRollback, onDelete, onViewSQL }: {migration.name}

{migration.appliedAt && ( + console.log(formatTimestamp(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/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/DatabaseOverviewPanel.tsx b/src/features/home/components/DatabaseOverviewPanel.tsx index 5e656c2..7ede4e4 100644 --- a/src/features/home/components/DatabaseOverviewPanel.tsx +++ b/src/features/home/components/DatabaseOverviewPanel.tsx @@ -1,6 +1,6 @@ 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 "../utils"; +import { formatRelativeTime } from "@/lib/utils"; import { DatabaseConnection } from "@/features/database/types"; import { ConnectionDetails } from "./ConnectionDetails"; import { MigrationStatusCard } from "./MigrationStatusCard"; diff --git a/src/features/home/components/WelcomeView.tsx b/src/features/home/components/WelcomeView.tsx index cb97e87..52145ef 100644 --- a/src/features/home/components/WelcomeView.tsx +++ b/src/features/home/components/WelcomeView.tsx @@ -9,9 +9,8 @@ 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"; diff --git a/src/features/home/components/index.ts b/src/features/home/components/index.ts index d183bf5..05ea8c1 100644 --- a/src/features/home/components/index.ts +++ b/src/features/home/components/index.ts @@ -5,4 +5,3 @@ export { AddConnectionDialog } from "./AddConnectionDialog"; export { DeleteDialog } from "./DeleteDialog"; export { DiscoveredDatabasesCard } from "./DiscoveredDatabasesCard"; export * from "../types"; -export * from "../utils"; 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/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); +} From e0b1e9a20ebda346e6593a3f15155e4901ff0e36 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 14 Jun 2026 00:30:29 +0530 Subject: [PATCH 12/17] fix: removed the @types/uuid for test cases --- bridge/package.json | 3 +-- bridge/pnpm-lock.yaml | 27 +++++---------------------- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/bridge/package.json b/bridge/package.json index 1f4c107..8f4cffb 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -33,7 +33,7 @@ "pg-query-stream": "^4.10.3", "pino": "^9.14.0", "ssh2": "^1.17.0", - "uuid": "^14.0.0", + "uuid": "^9.0.1", "ws": "^8.19.0" }, "bin": "./dist/index.cjs", @@ -52,7 +52,6 @@ "@types/jest": "^30.0.0", "@types/node": "^20.0.0", "@types/pg": "^8.15.6", - "@types/uuid": "^11.0.0", "@yao-pkg/pkg": "^5.12.0", "esbuild": "^0.27.0", "jest": "^30.2.0", diff --git a/bridge/pnpm-lock.yaml b/bridge/pnpm-lock.yaml index b7c4b25..2b63130 100644 --- a/bridge/pnpm-lock.yaml +++ b/bridge/pnpm-lock.yaml @@ -60,8 +60,8 @@ importers: specifier: ^1.17.0 version: 1.17.0 uuid: - specifier: ^14.0.0 - version: 14.0.0 + specifier: ^9.0.1 + version: 9.0.1 ws: specifier: ^8.19.0 version: 8.20.1 @@ -78,9 +78,6 @@ importers: '@types/pg': specifier: ^8.15.6 version: 8.20.0 - '@types/uuid': - specifier: ^11.0.0 - version: 11.0.0 '@yao-pkg/pkg': specifier: ^5.12.0 version: 5.16.1 @@ -737,10 +734,6 @@ packages: '@types/strip-json-comments@0.0.30': resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==} - '@types/uuid@11.0.0': - resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==} - deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed. - '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -2208,12 +2201,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - uuid@14.0.0: - resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} - hasBin: true - - 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 @@ -2999,10 +2988,6 @@ snapshots: '@types/strip-json-comments@0.0.30': {} - '@types/uuid@11.0.0': - dependencies: - uuid: 8.3.2 - '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -4618,9 +4603,7 @@ snapshots: util-deprecate@1.0.2: {} - uuid@14.0.0: {} - - uuid@8.3.2: {} + uuid@9.0.1: {} v8-compile-cache-lib@3.0.1: {} From b5f04591c1e3d8ecbaf0961b1fde42b669d64382 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 14 Jun 2026 00:43:43 +0530 Subject: [PATCH 13/17] refactor: fixed test case and added docker command in md and cleaned up mig panel --- CONTRIBUTING.md | 3 +++ bridge/__tests__/projectStore.test.ts | 12 +++++++++--- src/features/database/components/MigrationsPanel.tsx | 1 - 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e6f9c0..579ca0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,9 @@ 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/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/src/features/database/components/MigrationsPanel.tsx b/src/features/database/components/MigrationsPanel.tsx index aea23c7..2058b69 100644 --- a/src/features/database/components/MigrationsPanel.tsx +++ b/src/features/database/components/MigrationsPanel.tsx @@ -184,7 +184,6 @@ function MigrationItem({ migration, onApply, onRollback, onDelete, onViewSQL }: {migration.name}

{migration.appliedAt && ( - console.log(formatTimestamp(migration.appliedAt)),

Applied: {formatTimestamp(migration.appliedAt)}

From 87d72242c63fa3714fcdcd354b2c8c6576fa4485 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 14 Jun 2026 11:56:25 +0530 Subject: [PATCH 14/17] fix: syncSchemaFile expects a filesystem path --- bridge/src/handlers/projectHandlers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bridge/src/handlers/projectHandlers.ts b/bridge/src/handlers/projectHandlers.ts index 8eb9bfe..16c35de 100644 --- a/bridge/src/handlers/projectHandlers.ts +++ b/bridge/src/handlers/projectHandlers.ts @@ -221,7 +221,10 @@ export class ProjectHandlers { // Commit to Git if tracking try { - await gitServiceInstance.syncSchemaFile(projectId); + 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"); } From bdfbd0b48cedfa217647a3dd782df4592ad2e413 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 14 Jun 2026 13:18:28 +0530 Subject: [PATCH 15/17] fix: Migrations management and creation --- bridge/src/handlers/migrationHandlers.ts | 14 +++++++------- bridge/src/handlers/projectHandlers.ts | 4 +--- bridge/src/services/gitService.ts | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/bridge/src/handlers/migrationHandlers.ts b/bridge/src/handlers/migrationHandlers.ts index 7924fa6..9175295 100644 --- a/bridge/src/handlers/migrationHandlers.ts +++ b/bridge/src/handlers/migrationHandlers.ts @@ -315,15 +315,15 @@ export class MigrationHandlers { const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId); const versionStr = Date.now().toString(); - const tmpPath = path.join(migrationsDir, `${versionStr}_tmp_snapshot_apply.sql`); - fs.writeFileSync(tmpPath, baselineSQL, "utf8"); + const baselinePath = path.join(migrationsDir, `${versionStr}_apply_snapshot.sql`); + fs.writeFileSync(baselinePath, baselineSQL, "utf8"); - if (dbType === "mysql") await this.queryExecutor.mysql.applyMigration(conn, tmpPath); - else if (dbType === "postgres") await this.queryExecutor.postgres.applyMigration(conn, tmpPath); - else if (dbType === "mariadb") await this.queryExecutor.mariadb.applyMigration(conn, tmpPath); - else if (dbType === "sqlite") await this.queryExecutor.sqlite.applyMigration(conn, tmpPath); + 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(tmpPath)) fs.unlinkSync(tmpPath); + if (fs.existsSync(baselinePath)) fs.unlinkSync(baselinePath); try { const { writeMigrationLock } = await import("../services/migrationLock"); diff --git a/bridge/src/handlers/projectHandlers.ts b/bridge/src/handlers/projectHandlers.ts index 16c35de..473d252 100644 --- a/bridge/src/handlers/projectHandlers.ts +++ b/bridge/src/handlers/projectHandlers.ts @@ -533,9 +533,7 @@ export class ProjectHandlers { if (!projectDir) return this.rpc.sendError(id, { code: "NOT_FOUND", message: "Project directory not found" }); const { gitServiceInstance } = await import("../services/gitService"); - await gitServiceInstance.stageAll(projectDir); - await gitServiceInstance.commit(projectDir, "Push migrations"); - // Here you'd call git push if configured, for now just returning ok + await gitServiceInstance.pushMigrations(projectDir); this.rpc.sendResponse(id, { ok: true }); } catch (e: any) { this.logger?.error({ e }, "project.pushMigrations failed"); diff --git a/bridge/src/services/gitService.ts b/bridge/src/services/gitService.ts index 9337866..fbf5b5f 100644 --- a/bridge/src/services/gitService.ts +++ b/bridge/src/services/gitService.ts @@ -1116,7 +1116,7 @@ export class GitService { 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", "migration-lock.json"]; + const filesToStage = ["migrations"]; const stagedPaths: string[] = []; for (const file of filesToStage) { From 30f3f2c655ce453dc52f40e3755b5b0f43c1b8b6 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 14 Jun 2026 13:27:04 +0530 Subject: [PATCH 16/17] fix: migration-lock.json sync commit issue --- bridge/src/handlers/migrationHandlers.ts | 36 ++++++++++++------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/bridge/src/handlers/migrationHandlers.ts b/bridge/src/handlers/migrationHandlers.ts index 9175295..9cbdcbb 100644 --- a/bridge/src/handlers/migrationHandlers.ts +++ b/bridge/src/handlers/migrationHandlers.ts @@ -178,15 +178,7 @@ export class MigrationHandlers { const project = await projectStoreInstance.getProjectByDatabaseId(dbId); if (project) { - 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"); - } - } - - // Update Migration Lock + // 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); @@ -203,6 +195,14 @@ export class MigrationHandlers { 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"); @@ -388,15 +388,7 @@ export class MigrationHandlers { const project = await projectStoreInstance.getProjectByDatabaseId(dbId); if (project) { - 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"); - } - } - - // Update Migration Lock + // 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); @@ -413,6 +405,14 @@ export class MigrationHandlers { 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"); From 97f68610a3e8dc0a8875e7798adf9c3eefcde9b2 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 14 Jun 2026 13:31:56 +0530 Subject: [PATCH 17/17] feat: impl handleGenerateSQL --- bridge/src/handlers/projectHandlers.ts | 28 ++++++++++++++++++++++++++ bridge/src/jsonRpcHandler.ts | 3 +++ 2 files changed, 31 insertions(+) diff --git a/bridge/src/handlers/projectHandlers.ts b/bridge/src/handlers/projectHandlers.ts index 473d252..a52a9e2 100644 --- a/bridge/src/handlers/projectHandlers.ts +++ b/bridge/src/handlers/projectHandlers.ts @@ -827,4 +827,32 @@ export class ProjectHandlers { 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 7b515a6..9b0dc9a 100644 --- a/bridge/src/jsonRpcHandler.ts +++ b/bridge/src/jsonRpcHandler.ts @@ -260,6 +260,9 @@ export function registerDbHandlers( 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) );