diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 93f291d..579ca0d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -17,10 +17,27 @@ pnpm --dir bridge install
pnpm tauri dev
```
+To build the bridge package for your platform, run one of these from the repo root:
+
+```bash
+cd bridge
+pnpm build:pkg:win
+```
+
+or on Linux:
+
+```bash
+cd bridge
+pnpm build:pkg:linux
+```
+
If you are working on bridge-related code, you can run bridge tests directly:
```bash
cd bridge
+
+docker compose -f docker-compose.test.yml up -d
+
pnpm test
```
diff --git a/FEATURES.md b/FEATURES.md
index af6e460..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
diff --git a/bridge/__tests__/projectStore.test.ts b/bridge/__tests__/projectStore.test.ts
index a3a5f2d..8acecda 100644
--- a/bridge/__tests__/projectStore.test.ts
+++ b/bridge/__tests__/projectStore.test.ts
@@ -257,9 +257,12 @@ describe("ProjectStore", () => {
name: "users",
type: "BASE TABLE",
columns: [
- { name: "id", type: "integer", nullable: false, isPrimaryKey: true, isForeignKey: false, defaultValue: null, isUnique: true },
- { name: "email", type: "varchar(255)", nullable: false, isPrimaryKey: false, isForeignKey: false, defaultValue: null, isUnique: true },
+ { name: "id", type: "integer", nullable: false, isPrimaryKey: true, isForeignKey: false, defaultValue: null, isUnique: true, isSerial: true, ordinalPosition: 1 },
+ { name: "email", type: "varchar(255)", nullable: false, isPrimaryKey: false, isForeignKey: false, defaultValue: null, isUnique: true, isSerial: false, ordinalPosition: 2 },
],
+ indexes: [],
+ foreignKeys: [],
+ checks: [],
},
],
},
@@ -301,8 +304,11 @@ describe("ProjectStore", () => {
name: "posts",
type: "BASE TABLE",
columns: [
- { name: "id", type: "integer", nullable: false, isPrimaryKey: true, isForeignKey: false, defaultValue: null, isUnique: true },
+ { name: "id", type: "integer", nullable: false, isPrimaryKey: true, isForeignKey: false, defaultValue: null, isUnique: true, isSerial: true, ordinalPosition: 1 },
],
+ indexes: [],
+ foreignKeys: [],
+ checks: [],
},
],
},
diff --git a/bridge/package.json b/bridge/package.json
index 9e1b42c..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": "^8.3.2",
+ "uuid": "^9.0.1",
"ws": "^8.19.0"
},
"bin": "./dist/index.cjs",
diff --git a/bridge/pnpm-lock.yaml b/bridge/pnpm-lock.yaml
index f00a3ba..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: ^8.3.2
- version: 8.3.2
+ specifier: ^9.0.1
+ version: 9.0.1
ws:
specifier: ^8.19.0
version: 8.20.1
@@ -2201,8 +2201,8 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
- uuid@8.3.2:
- resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
+ uuid@9.0.1:
+ resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
@@ -4603,7 +4603,7 @@ snapshots:
util-deprecate@1.0.2: {}
- uuid@8.3.2: {}
+ uuid@9.0.1: {}
v8-compile-cache-lib@3.0.1: {}
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) {
diff --git a/bridge/src/connectors/README.md b/bridge/src/connectors/README.md
new file mode 100644
index 0000000..a94891f
--- /dev/null
+++ b/bridge/src/connectors/README.md
@@ -0,0 +1,26 @@
+# Database Connectors (Bridge)
+
+This folder contains database-specific connector implementations used by the bridge to execute metadata, query, migration and CRUD operations.
+
+Layout
+- `postgres.ts` - PostgreSQL connector, cache manager, metadata helpers, streaming query support and PostgreSQL-specific DDL/DML behavior.
+- `mysql.ts` - MySQL connector for schema/table metadata, stats, migrations and query execution.
+- `mariadb.ts` - MariaDB connector. Keep MariaDB behavior separate when it diverges from MySQL.
+- `sqlite.ts` - SQLite connector using `better-sqlite3`, local file handling and SQLite-specific migration/query behavior.
+
+How it fits
+- `src/services/queryExecutor.ts` routes bridge operations to the correct connector based on `DBType`.
+- SQL strings should come from `src/queries/*` where practical, not be duplicated inline.
+- Shared result shapes come from `src/types/common.ts`; database-specific config and metadata types live in `src/types/postgres.ts`, `src/types/mysql.ts` and `src/types/sqlite.ts`.
+- Connection config objects are built by `src/services/connectionBuilder.ts`; connectors generally create their own driver client from that config for each operation.
+
+How to add connector behavior
+1. Add database-specific SQL to `src/queries//` when the operation needs raw SQL.
+2. Add or update typed method support in the relevant connector file.
+3. Route the operation from `QueryExecutor` or the owning service.
+4. Register any new public RPC method through a handler in `src/handlers`.
+
+Notes
+- Keep connector methods focused on database interaction. Do not read UI payloads directly or handle RPC responses here.
+- Invalidate or bypass connector caches when an operation mutates schema, table data or migration state.
+- Prefer shared common types unless a database really needs extra metadata fields.
diff --git a/bridge/src/connectors/mariadb.ts b/bridge/src/connectors/mariadb.ts
index 8ce9151..6c77d95 100644
--- a/bridge/src/connectors/mariadb.ts
+++ b/bridge/src/connectors/mariadb.ts
@@ -7,7 +7,8 @@ import mysql, {
import { loadLocalMigrations, writeBaselineMigration } from "../utils/baselineMigration";
import crypto from "crypto";
import fs from "fs";
-import { ensureDir, getMigrationsDir } from "../utils/config";
+import { ensureDir } from "../utils/config";
+import { projectStoreInstance } from "../services/projectStore";
import {
CacheEntry,
CACHE_TTL,
@@ -34,6 +35,7 @@ import {
MySQLAlterTableOperation as MariaDBAlterTableOperation,
MySQLDropMode as MariaDBDropMode,
} from "../types/mysql";
+import { SchemaFile } from "../services/projectStore";
export type {
ColumnDetail,
@@ -548,7 +550,7 @@ export function streamQueryCancelable(
conn = await pool.getConnection();
const [pidRows] = await conn.execute(GET_CONNECTION_ID);
- backendPid = pidRows[0].pid;
+ backendPid = (pidRows as any[])[0].pid;
const raw = (conn as any).connection;
query = raw.query(sql);
@@ -916,7 +918,12 @@ export async function getSchemaMetadataBatch(
not_nullable: Boolean(row.not_nullable),
default_value: row.default_value,
is_primary_key: Boolean(row.is_primary_key),
- is_foreign_key: Boolean(row.is_foreign_key)
+ is_foreign_key: Boolean(row.is_foreign_key),
+ is_unique: Boolean(row.is_unique),
+ is_serial: Boolean(row.is_serial),
+ comment: row.comment,
+ check_constraint: row.check_constraint,
+ ordinal_position: row.ordinal_position
});
}
@@ -1349,10 +1356,10 @@ export async function insertBaseline(
await connection.query(INSERT_MIGRATION, [version, name, checksum]);
}
-
export async function baselineIfNeeded(
conn: MariaDBConfig,
- migrationsDir: string
+ migrationsDir: string,
+ snapshot?: SchemaFile
) {
try {
await ensureMigrationTable(conn);
@@ -1363,10 +1370,22 @@ export async function baselineIfNeeded(
const version = Date.now().toString();
const name = "baseline_existing_schema";
+ const fakeSnapshot = snapshot || {
+ version: 2,
+ projectId: "",
+ databaseId: "",
+ dialect: "mysql",
+ schemas: [],
+ cachedAt: "",
+ relwaveVersion: "",
+ schemaHash: ""
+ };
+
const filePath = writeBaselineMigration(
migrationsDir,
version,
- name
+ name,
+ fakeSnapshot
);
const checksum = crypto
@@ -1420,11 +1439,19 @@ export async function connectToDatabase(
options?: { readOnly?: boolean }
) {
let baselineResult = { baselined: false };
- const migrationsDir = getMigrationsDir(connectionId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(connectionId);
ensureDir(migrationsDir);
// 1️⃣ Baseline (ONLY if not read-only)
if (!options?.readOnly) {
- baselineResult = await baselineIfNeeded(cfg, migrationsDir);
+ // Pass real schema snapshot so baseline contains actual DDL
+ let snapshot: any = undefined;
+ try {
+ const project = await projectStoreInstance.getProjectByDatabaseId(connectionId);
+ if (project) {
+ snapshot = await projectStoreInstance.getSchema(project.id) || undefined;
+ }
+ } catch { }
+ baselineResult = await baselineIfNeeded(cfg, migrationsDir, snapshot);
}
// 2️⃣ Load schema (read-only introspection)
diff --git a/bridge/src/connectors/mysql.ts b/bridge/src/connectors/mysql.ts
index 72acba9..dd49a42 100644
--- a/bridge/src/connectors/mysql.ts
+++ b/bridge/src/connectors/mysql.ts
@@ -7,7 +7,8 @@ import mysql, {
import { loadLocalMigrations, writeBaselineMigration } from "../utils/baselineMigration";
import crypto from "crypto";
import fs from "fs";
-import { ensureDir, getMigrationsDir } from "../utils/config";
+import { ensureDir } from "../utils/config";
+import { projectStoreInstance } from "../services/projectStore";
import {
CacheEntry,
CACHE_TTL,
@@ -34,6 +35,7 @@ import {
MySQLAlterTableOperation,
MySQLDropMode,
} from "../types/mysql";
+import { SchemaFile } from "../services/projectStore";
// Re-export types for backward compatibility
export type {
@@ -523,7 +525,7 @@ export function streamQueryCancelable(
conn = await pool.getConnection();
const [pidRows] = await conn.execute(GET_CONNECTION_ID);
- backendPid = pidRows[0].pid;
+ backendPid = (pidRows as any[])[0].pid;
const raw = (conn as any).connection;
query = raw.query(sql);
@@ -892,7 +894,12 @@ export async function getSchemaMetadataBatch(
not_nullable: Boolean(row.not_nullable),
default_value: row.default_value,
is_primary_key: Boolean(row.is_primary_key),
- is_foreign_key: Boolean(row.is_foreign_key)
+ is_foreign_key: Boolean(row.is_foreign_key),
+ is_unique: Boolean(row.is_unique),
+ is_serial: Boolean(row.is_serial),
+ comment: row.comment,
+ check_constraint: row.check_constraint,
+ ordinal_position: row.ordinal_position
});
}
@@ -1103,7 +1110,7 @@ function groupMySQLIndexes(indexes: IndexInfo[]) {
}
return [...map.values()].map(group =>
- group.sort((a, b) => a.seq_in_index - b.seq_in_index)
+ group.sort((a, b) => (a.seq_in_index ?? 0) - (b.seq_in_index ?? 0))
);
}
@@ -1324,10 +1331,10 @@ export async function insertBaseline(
await connection.query(INSERT_MIGRATION, [version, name, checksum]);
}
-
export async function baselineIfNeeded(
conn: MySQLConfig,
- migrationsDir: string
+ migrationsDir: string,
+ snapshot?: SchemaFile
) {
try {
await ensureMigrationTable(conn);
@@ -1338,10 +1345,22 @@ export async function baselineIfNeeded(
const version = Date.now().toString();
const name = "baseline_existing_schema";
+ const fakeSnapshot = snapshot || {
+ version: 2,
+ projectId: "",
+ databaseId: "",
+ dialect: "mysql",
+ schemas: [],
+ cachedAt: "",
+ relwaveVersion: "",
+ schemaHash: ""
+ };
+
const filePath = writeBaselineMigration(
migrationsDir,
version,
- name
+ name,
+ fakeSnapshot
);
const checksum = crypto
@@ -1395,11 +1414,19 @@ export async function connectToDatabase(
options?: { readOnly?: boolean }
) {
let baselineResult = { baselined: false };
- const migrationsDir = getMigrationsDir(connectionId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(connectionId);
ensureDir(migrationsDir);
// 1️⃣ Baseline (ONLY if not read-only)
if (!options?.readOnly) {
- baselineResult = await baselineIfNeeded(cfg, migrationsDir);
+ // Pass real schema snapshot so baseline contains actual DDL
+ let snapshot: any = undefined;
+ try {
+ const project = await projectStoreInstance.getProjectByDatabaseId(connectionId);
+ if (project) {
+ snapshot = await projectStoreInstance.getSchema(project.id) || undefined;
+ }
+ } catch {}
+ baselineResult = await baselineIfNeeded(cfg, migrationsDir, snapshot);
}
// 2️⃣ Load schema (read-only introspection)
diff --git a/bridge/src/connectors/postgres.ts b/bridge/src/connectors/postgres.ts
index 19084dc..4f56d94 100644
--- a/bridge/src/connectors/postgres.ts
+++ b/bridge/src/connectors/postgres.ts
@@ -5,7 +5,8 @@ import { Readable } from "stream";
import { loadLocalMigrations, writeBaselineMigration } from "../utils/baselineMigration";
import crypto from "crypto";
import fs from "fs";
-import { ensureDir, getMigrationsDir } from "../utils/config";
+import { ensureDir } from "../utils/config";
+import { projectStoreInstance } from "../services/projectStore";
import {
CacheEntry,
CACHE_TTL,
@@ -32,6 +33,7 @@ import {
PGAlterTableOperation,
PGDropMode,
} from "../types/postgres";
+import { SchemaFile } from "../services/projectStore";
export type {
PGConfig,
@@ -801,7 +803,12 @@ export async function getSchemaMetadataBatch(
not_nullable: row.not_nullable,
default_value: row.default_value,
is_primary_key: row.is_primary_key,
- is_foreign_key: row.is_foreign_key
+ is_foreign_key: row.is_foreign_key,
+ is_unique: row.is_unique,
+ is_serial: row.is_serial,
+ check_constraint: row.check_constraint,
+ comment: row.comment,
+ ordinal_position: row.ordinal_position
});
}
@@ -1169,7 +1176,7 @@ function groupIndexes(indexes: IndexInfo[]) {
}
return [...map.values()].map(group =>
- group.sort((a, b) => a.ordinal_position - b.ordinal_position)
+ group.sort((a, b) => (a.ordinal_position ?? 0) - (b.ordinal_position ?? 0))
);
}
@@ -1409,10 +1416,10 @@ export async function insertBaseline(
}
}
-
export async function baselineIfNeeded(
conn: PGConfig,
- migrationsDir: string
+ migrationsDir: string,
+ snapshot?: SchemaFile
) {
const client = createClient(conn);
@@ -1426,10 +1433,22 @@ export async function baselineIfNeeded(
const version = Date.now().toString();
const name = "baseline_existing_schema";
+ const fakeSnapshot = snapshot || {
+ version: 2,
+ projectId: "",
+ databaseId: "",
+ dialect: "postgresql",
+ schemas: [],
+ cachedAt: "",
+ relwaveVersion: "",
+ schemaHash: ""
+ };
+
const filePath = writeBaselineMigration(
migrationsDir,
version,
- name
+ name,
+ fakeSnapshot
);
const checksum = crypto
@@ -1485,11 +1504,19 @@ export async function connectToDatabase(
) {
// 1️⃣ Baseline (only if allowed)
let baselineResult = { baselined: false };
- const migrationsDir = getMigrationsDir(connectionId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(connectionId);
ensureDir(migrationsDir);
if (!options?.readOnly) {
- baselineResult = await baselineIfNeeded(cfg, migrationsDir);
+ // Pass real schema snapshot so baseline contains actual DDL
+ let snapshot: any = undefined;
+ try {
+ const project = await projectStoreInstance.getProjectByDatabaseId(connectionId);
+ if (project) {
+ snapshot = await projectStoreInstance.getSchema(project.id) || undefined;
+ }
+ } catch { }
+ baselineResult = await baselineIfNeeded(cfg, migrationsDir, snapshot);
}
// 2️⃣ Load schema (read-only)
diff --git a/bridge/src/connectors/sqlite.ts b/bridge/src/connectors/sqlite.ts
index 0815fb0..38b56e4 100644
--- a/bridge/src/connectors/sqlite.ts
+++ b/bridge/src/connectors/sqlite.ts
@@ -5,7 +5,8 @@ import crypto from "crypto";
import fs from "fs";
import os from "os";
import path from "path";
-import { ensureDir, getMigrationsDir } from "../utils/config";
+import { ensureDir } from "../utils/config";
+import { projectStoreInstance } from "../services/projectStore";
import { isWindowsDriveRootPath, normalizeSQLitePath } from "../utils/sqlitePath";
import {
CacheEntry,
@@ -55,6 +56,7 @@ import {
} from "../queries/sqlite/migrations";
import { SQLITE_GET_TABLE_SQL } from "../queries/sqlite/constraints";
import { sqliteQuoteIdentifier } from "../queries/sqlite/crud";
+import { SchemaFile } from "../services/projectStore";
// ============================================
// CACHING SYSTEM FOR SQLITE CONNECTOR
@@ -322,7 +324,7 @@ function getInstalledBindingCandidates(execDir: string): string[] {
return Array.from(searchRoots, (root) => path.join(root, "better_sqlite3.node"));
}
-function resolvePkgNativeBindingPath(): string | undefined {
+export function resolvePkgNativeBindingPath(): string | undefined {
if (cachedNativeBindingPath !== undefined) {
return cachedNativeBindingPath ?? undefined;
}
@@ -376,7 +378,9 @@ function resolvePkgNativeBindingPath(): string | undefined {
const snapshotRoot = path.join(path.sep, "snapshot", "bridge", "node_modules");
snapshotCandidates.push(
path.join(snapshotRoot, "better-sqlite3", "build", "Release", "better_sqlite3.node"),
- path.join(snapshotRoot, ".pnpm", "better-sqlite3@12.6.2", "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node")
+ path.join(snapshotRoot, ".pnpm", "better-sqlite3@12.6.2", "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node"),
+ path.join(snapshotRoot, ".pnpm", "better-sqlite3@11.10.0", "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node"),
+ path.join(snapshotRoot, ".pnpm", "better-sqlite3@11.9.0", "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node")
);
const targetNodeFile = path.join(os.tmpdir(), `relwave-better_sqlite3-${process.pid}.node`);
@@ -732,6 +736,11 @@ export async function getSchemaMetadataBatch(
default_value: col.dflt_value,
is_primary_key: col.pk > 0,
is_foreign_key: fkColumns.has(col.name),
+ is_unique: false, // will be updated below if unique index exists
+ is_serial: col.pk > 0 && (col.type || '').toLowerCase() === 'integer',
+ check_constraint: undefined,
+ comment: undefined,
+ ordinal_position: col.cid + 1,
}));
const primaryKeys: PrimaryKeyInfo[] = cols
@@ -769,14 +778,19 @@ export async function getSchemaMetadataBatch(
ordinal_position: col.seqno,
});
- if (idx.unique === 1 && idx.origin !== 'pk') {
- uniqueConstraints.push({
- constraint_name: idx.name,
- table_schema: 'main',
- table_name: tableName,
- column_name: col.name,
- ordinal_position: col.seqno,
- });
+ if (idx.unique === 1) {
+ const c = columns.find(c => c.name === col.name);
+ if (c) c.is_unique = true;
+
+ if (idx.origin !== 'pk') {
+ uniqueConstraints.push({
+ constraint_name: idx.name,
+ table_schema: 'main',
+ table_name: tableName,
+ column_name: col.name,
+ ordinal_position: col.seqno,
+ });
+ }
}
}
}
@@ -1097,7 +1111,8 @@ export async function insertBaseline(
/** Baseline if needed */
export async function baselineIfNeeded(
cfg: SQLiteConfig,
- migrationsDir: string
+ migrationsDir: string,
+ snapshot?: SchemaFile
) {
await ensureMigrationTable(cfg);
@@ -1107,7 +1122,18 @@ export async function baselineIfNeeded(
const version = Date.now().toString();
const name = "baseline_existing_schema";
- const filePath = writeBaselineMigration(migrationsDir, version, name);
+ const fakeSnapshot = snapshot || {
+ version: 2,
+ projectId: "",
+ databaseId: "",
+ dialect: "sqlite",
+ schemas: [],
+ cachedAt: "",
+ relwaveVersion: "",
+ schemaHash: ""
+ };
+
+ const filePath = writeBaselineMigration(migrationsDir, version, name, fakeSnapshot);
const checksum = crypto
.createHash("sha256")
@@ -1142,11 +1168,19 @@ export async function connectToDatabase(
options?: { readOnly?: boolean }
) {
let baselineResult = { baselined: false };
- const migrationsDir = getMigrationsDir(connectionId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(connectionId);
ensureDir(migrationsDir);
if (!options?.readOnly) {
- baselineResult = await baselineIfNeeded(cfg, migrationsDir);
+ // Pass real schema snapshot so baseline contains actual DDL
+ let snapshot: any = undefined;
+ try {
+ const project = await projectStoreInstance.getProjectByDatabaseId(connectionId);
+ if (project) {
+ snapshot = await projectStoreInstance.getSchema(project.id) || undefined;
+ }
+ } catch {}
+ baselineResult = await baselineIfNeeded(cfg, migrationsDir, snapshot);
}
const schema = await listSchemas(cfg);
diff --git a/bridge/src/handlers/README.md b/bridge/src/handlers/README.md
new file mode 100644
index 0000000..110354b
--- /dev/null
+++ b/bridge/src/handlers/README.md
@@ -0,0 +1,33 @@
+# RPC Handlers (Bridge)
+
+This folder contains JSON-RPC handler classes. Handlers translate frontend RPC calls into service/query work and send normalized RPC responses or errors.
+
+Layout
+- `databaseHandlers.ts` - database connection CRUD, connection tests and schema/table metadata entry points.
+- `queryHandlers.ts` - query execution, table browsing and table/row mutation handlers.
+- `sessionHandlers.ts` - query session lifecycle and cancellation.
+- `statsHandlers.ts` - database and aggregate statistics.
+- `migrationHandlers.ts` - migration generation, application, rollback, deletion and SQL retrieval.
+- `projectHandlers.ts` - project persistence, schema snapshots, ER diagrams, annotations, saved queries and import/export.
+- `gitHandlers.ts` - core local Git actions.
+- `gitAdvancedHandlers.ts` - remotes, push/pull/fetch and revert operations.
+- `monitoringHandlers.ts` - database monitoring snapshot and websocket info endpoints.
+- `aiHandlers.ts` - AI provider tests, schema analysis, query explanation, chart recommendation and history.
+
+How it fits
+- `src/jsonRpcHandler.ts` constructs handlers and registers public method names such as `db.list`, `query.run`, `project.create`, `git.status` and `ai.analyzeSchema`.
+- Handlers depend on services like `DatabaseService`, `QueryExecutor`, `MonitoringService` and `GitService`.
+- Handlers own RPC validation and error mapping. Services and connectors should throw normal errors rather than calling `rpc.sendResponse` directly.
+
+How to add an RPC method
+1. Add a handler method in the correct handler class.
+2. Validate required params near the top of the handler.
+3. Call service/query logic and return `{ ok: true, data }` through `rpc.sendResponse`.
+4. Convert failures to `rpc.sendError(id, { code, message })`.
+5. Register the method in `src/jsonRpcHandler.ts`.
+6. Add or update the matching frontend bridge service in `src/services/bridge`.
+
+Notes
+- Keep handler classes thin. If logic grows beyond request validation and orchestration, move it into `src/services`.
+- Avoid leaking credentials in responses; strip or omit password and credential identifiers before responding.
+- Use stable error codes because the frontend may surface them in notifications.
diff --git a/bridge/src/handlers/migrationHandlers.ts b/bridge/src/handlers/migrationHandlers.ts
index f4cefd8..9cbdcbb 100644
--- a/bridge/src/handlers/migrationHandlers.ts
+++ b/bridge/src/handlers/migrationHandlers.ts
@@ -2,9 +2,9 @@ import { Rpc } from "../types";
import { DatabaseService } from "../services/databaseService";
import { QueryExecutor } from "../services/queryExecutor";
import { Logger } from "pino";
-import { getMigrationsDir } from "../utils/config";
import path from "path";
import fs from "fs";
+import { projectStoreInstance } from "../services/projectStore";
export class MigrationHandlers {
constructor(
@@ -26,7 +26,7 @@ export class MigrationHandlers {
}
const { conn, dbType } = await this.dbService.getDatabaseConnection(dbId);
- const migrationsDir = getMigrationsDir(dbId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
// Generate migration file
const { generateCreateTableMigration, writeMigrationFile } = await import('../utils/migrationGenerator');
@@ -66,7 +66,7 @@ export class MigrationHandlers {
}
const { conn, dbType } = await this.dbService.getDatabaseConnection(dbId);
- const migrationsDir = getMigrationsDir(dbId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
// Generate migration file
const { generateAlterTableMigration, writeMigrationFile } = await import('../utils/migrationGenerator');
@@ -105,7 +105,7 @@ export class MigrationHandlers {
}
const { conn, dbType } = await this.dbService.getDatabaseConnection(dbId);
- const migrationsDir = getMigrationsDir(dbId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
// Generate migration file
const { generateDropTableMigration, writeMigrationFile } = await import('../utils/migrationGenerator');
@@ -144,7 +144,7 @@ export class MigrationHandlers {
}
const { conn, dbType } = await this.dbService.getDatabaseConnection(dbId);
- const migrationsDir = getMigrationsDir(dbId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
// Find migration file
const { listMigrationFiles } = await import('../utils/migrationFileReader');
@@ -160,7 +160,6 @@ export class MigrationHandlers {
const migrationFilePath = path.join(migrationsDir, migrationFile);
- // Apply migration
if (dbType === "mysql") {
await this.queryExecutor.mysql.applyMigration(conn, migrationFilePath);
} else if (dbType === "postgres") {
@@ -171,6 +170,44 @@ export class MigrationHandlers {
await this.queryExecutor.sqlite.applyMigration(conn, migrationFilePath);
}
+ // Hook syncMigrationFiles
+ try {
+ const { projectStoreInstance } = await import("../services/projectStore");
+ const { gitServiceInstance } = await import("../services/gitService");
+ const { writeMigrationLock } = await import("../services/migrationLock");
+
+ const project = await projectStoreInstance.getProjectByDatabaseId(dbId);
+ if (project) {
+ // Update Migration Lock first so it gets included in the git sync commit
+ let appliedMigrations: any[] = [];
+ if (dbType === "mysql") {
+ appliedMigrations = await require("../connectors/mysql").listAppliedMigrations(conn);
+ } else if (dbType === "postgres") {
+ appliedMigrations = await require("../connectors/postgres").listAppliedMigrations(conn);
+ } else if (dbType === "mariadb") {
+ appliedMigrations = await require("../connectors/mariadb").listAppliedMigrations(conn);
+ } else if (dbType === "sqlite") {
+ appliedMigrations = await require("../connectors/sqlite").listAppliedMigrations(conn);
+ }
+
+ const schemaFile = await projectStoreInstance.getSchema(project.id);
+ const schemaHash = (schemaFile as any)?.schemaHash || "";
+
+ const appliedVersions = appliedMigrations.map(m => m.version);
+ await writeMigrationLock(dbId, schemaHash, appliedVersions);
+
+ const projectDir = await projectStoreInstance.resolveProjectDir(project.id);
+ if (projectDir) {
+ const syncResult = await gitServiceInstance.syncMigrationFiles(projectDir);
+ if (syncResult.error) {
+ this.logger?.warn({ error: syncResult.error }, "Git sync failed after migration apply");
+ }
+ }
+ }
+ } catch (syncErr) {
+ this.logger?.error({ err: syncErr }, "syncMigrationFiles/lock hook failed");
+ }
+
this.rpc.sendResponse(id, { ok: true });
} catch (e: any) {
this.logger?.error({ e }, "migration.apply failed");
@@ -178,6 +215,132 @@ export class MigrationHandlers {
}
}
+ async handleApplyMigrations(params: any, id: number | string) {
+ try {
+ let { dbId, projectId } = params || {};
+
+ // Resolve dbId from projectId if not directly provided
+ if (!dbId && projectId) {
+ const project = await projectStoreInstance.getProject(projectId);
+ if (!project?.databaseId) {
+ return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Project has no linked database" });
+ }
+ dbId = project.databaseId;
+ }
+ if (!dbId) return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing dbId or projectId" });
+
+ const { conn, dbType } = await this.dbService.getDatabaseConnection(dbId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
+
+ const { loadLocalMigrations } = await import('../utils/baselineMigration');
+ const localMigrations = await loadLocalMigrations(migrationsDir);
+
+ let connector: any;
+ if (dbType === "mysql") connector = require("../connectors/mysql");
+ else if (dbType === "postgres") connector = require("../connectors/postgres");
+ else if (dbType === "mariadb") connector = require("../connectors/mariadb");
+ else if (dbType === "sqlite") connector = require("../connectors/sqlite");
+
+ const appliedMigrations = await connector.listAppliedMigrations(conn);
+ const appliedSet = new Set(appliedMigrations.map((m: any) => m.version));
+
+ const pending = localMigrations.filter(m => !appliedSet.has(m.version)).sort((a, b) => a.version.localeCompare(b.version));
+
+ for (const migration of pending) {
+ const migrationFile = (await fs.promises.readdir(migrationsDir)).find(f => f.startsWith(migration.version));
+ if (!migrationFile) throw new Error(`Migration file not found for version: ${migration.version}`);
+
+ const migrationFilePath = path.join(migrationsDir, migrationFile);
+ if (dbType === "mysql") await this.queryExecutor.mysql.applyMigration(conn, migrationFilePath);
+ else if (dbType === "postgres") await this.queryExecutor.postgres.applyMigration(conn, migrationFilePath);
+ else if (dbType === "mariadb") await this.queryExecutor.mariadb.applyMigration(conn, migrationFilePath);
+ else if (dbType === "sqlite") await this.queryExecutor.sqlite.applyMigration(conn, migrationFilePath);
+ }
+
+ try {
+ const { projectStoreInstance } = await import("../services/projectStore");
+ const { writeMigrationLock } = await import("../services/migrationLock");
+ const project = await projectStoreInstance.getProjectByDatabaseId(dbId);
+ if (project) {
+ const schemaFile = await projectStoreInstance.getSchema(project.id);
+ const schemaHash = (schemaFile as any)?.schemaHash || "";
+ const updatedApplied = await connector.listAppliedMigrations(conn);
+ await writeMigrationLock(dbId, schemaHash, updatedApplied.map((m: any) => m.version));
+ }
+ } catch (syncErr) {
+ this.logger?.error({ err: syncErr }, "Lock hook failed");
+ }
+
+ this.rpc.sendResponse(id, { ok: true, count: pending.length });
+ } catch (e: any) {
+ this.logger?.error({ e }, "migration.applyMigrations failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
+ async handleApplySnapshot(params: any, id: number | string) {
+ try {
+ let { dbId, projectId } = params || {};
+ const { projectStoreInstance } = await import("../services/projectStore");
+
+ // Resolve dbId from projectId if not directly provided
+ if (!dbId && projectId) {
+ const project = await projectStoreInstance.getProject(projectId);
+ if (!project?.databaseId) {
+ return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Project has no linked database" });
+ }
+ dbId = project.databaseId;
+ }
+ if (!dbId) return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing dbId or projectId" });
+
+ const { conn, dbType } = await this.dbService.getDatabaseConnection(dbId);
+
+ const project = await projectStoreInstance.getProjectByDatabaseId(dbId);
+ if (!project) throw new Error("Project not found for database");
+ const snapshot = await projectStoreInstance.getSchema(project.id);
+ if (!snapshot) throw new Error("Schema snapshot not found");
+
+ const { generateBaselineSQL } = await import("../utils/baselineMigration");
+ const baselineSQL = generateBaselineSQL(snapshot, Date.now().toString(), "apply_snapshot");
+
+ let connector: any;
+ if (dbType === "mysql") connector = require("../connectors/mysql");
+ else if (dbType === "postgres") connector = require("../connectors/postgres");
+ else if (dbType === "mariadb") connector = require("../connectors/mariadb");
+ else if (dbType === "sqlite") connector = require("../connectors/sqlite");
+
+ // For full snapshot apply, we assume DB is empty or we should drop it.
+ // A full implementation would drop tables here.
+ // For now, we write the SQL and apply it.
+
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
+ const versionStr = Date.now().toString();
+ const baselinePath = path.join(migrationsDir, `${versionStr}_apply_snapshot.sql`);
+ fs.writeFileSync(baselinePath, baselineSQL, "utf8");
+
+ if (dbType === "mysql") await this.queryExecutor.mysql.applyMigration(conn, baselinePath);
+ else if (dbType === "postgres") await this.queryExecutor.postgres.applyMigration(conn, baselinePath);
+ else if (dbType === "mariadb") await this.queryExecutor.mariadb.applyMigration(conn, baselinePath);
+ else if (dbType === "sqlite") await this.queryExecutor.sqlite.applyMigration(conn, baselinePath);
+
+ if (fs.existsSync(baselinePath)) fs.unlinkSync(baselinePath);
+
+ try {
+ const { writeMigrationLock } = await import("../services/migrationLock");
+ const schemaHash = (snapshot as any)?.schemaHash || "";
+ const updatedApplied = await connector.listAppliedMigrations(conn);
+ await writeMigrationLock(dbId, schemaHash, updatedApplied.map((m: any) => m.version));
+ } catch (syncErr) {
+ this.logger?.error({ err: syncErr }, "Lock hook failed");
+ }
+
+ this.rpc.sendResponse(id, { ok: true });
+ } catch (e: any) {
+ this.logger?.error({ e }, "migration.applySnapshot failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
async handleRollbackMigration(params: any, id: number | string) {
try {
const { dbId, version } = params || {};
@@ -190,7 +353,7 @@ export class MigrationHandlers {
}
const { conn, dbType } = await this.dbService.getDatabaseConnection(dbId);
- const migrationsDir = getMigrationsDir(dbId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
// Find migration file
const { listMigrationFiles } = await import('../utils/migrationFileReader');
@@ -217,6 +380,44 @@ export class MigrationHandlers {
await this.queryExecutor.sqlite.rollbackMigration(conn, version, migrationFilePath);
}
+ // Hook syncMigrationFiles and Lock Update
+ try {
+ const { projectStoreInstance } = await import("../services/projectStore");
+ const { gitServiceInstance } = await import("../services/gitService");
+ const { writeMigrationLock } = await import("../services/migrationLock");
+
+ const project = await projectStoreInstance.getProjectByDatabaseId(dbId);
+ if (project) {
+ // Update Migration Lock first so it gets included in the git sync commit
+ let appliedMigrations: any[] = [];
+ if (dbType === "mysql") {
+ appliedMigrations = await require("../connectors/mysql").listAppliedMigrations(conn);
+ } else if (dbType === "postgres") {
+ appliedMigrations = await require("../connectors/postgres").listAppliedMigrations(conn);
+ } else if (dbType === "mariadb") {
+ appliedMigrations = await require("../connectors/mariadb").listAppliedMigrations(conn);
+ } else if (dbType === "sqlite") {
+ appliedMigrations = await require("../connectors/sqlite").listAppliedMigrations(conn);
+ }
+
+ const schemaFile = await projectStoreInstance.getSchema(project.id);
+ const schemaHash = (schemaFile as any)?.schemaHash || "";
+
+ const appliedVersions = appliedMigrations.map(m => m.version);
+ await writeMigrationLock(dbId, schemaHash, appliedVersions);
+
+ const projectDir = await projectStoreInstance.resolveProjectDir(project.id);
+ if (projectDir) {
+ const syncResult = await gitServiceInstance.syncMigrationFiles(projectDir);
+ if (syncResult.error) {
+ this.logger?.warn({ error: syncResult.error }, "Git sync failed after migration rollback");
+ }
+ }
+ }
+ } catch (syncErr) {
+ this.logger?.error({ err: syncErr }, "syncMigrationFiles/lock hook failed");
+ }
+
this.rpc.sendResponse(id, { ok: true });
} catch (e: any) {
this.logger?.error({ e }, "migration.rollback failed");
@@ -235,7 +436,7 @@ export class MigrationHandlers {
});
}
- const migrationsDir = getMigrationsDir(dbId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
// Find and delete migration file
const { listMigrationFiles } = await import('../utils/migrationFileReader');
@@ -270,7 +471,7 @@ export class MigrationHandlers {
});
}
- const migrationsDir = getMigrationsDir(dbId);
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
// Find and read migration file
const { listMigrationFiles, readMigrationFile } = await import('../utils/migrationFileReader');
diff --git a/bridge/src/handlers/projectHandlers.ts b/bridge/src/handlers/projectHandlers.ts
index d9f3f22..a52a9e2 100644
--- a/bridge/src/handlers/projectHandlers.ts
+++ b/bridge/src/handlers/projectHandlers.ts
@@ -1,6 +1,10 @@
import { Rpc } from "../types";
import { Logger } from "pino";
import { projectStoreInstance } from "../services/projectStore";
+import { DatabaseService } from "../services/databaseService";
+import { QueryExecutor } from "../services/queryExecutor";
+import { gitServiceInstance } from "../services/gitService";
+import path from "path";
/**
* RPC handlers for project CRUD and sub-resource operations.
@@ -9,7 +13,9 @@ import { projectStoreInstance } from "../services/projectStore";
export class ProjectHandlers {
constructor(
private rpc: Rpc,
- private logger: Logger
+ private logger: Logger,
+ private dbService: DatabaseService,
+ private queryExecutor: QueryExecutor
) { }
@@ -170,6 +176,67 @@ export class ProjectHandlers {
}
}
+ async handleRefreshSchemaCache(params: any, id: number | string) {
+ try {
+ const { projectId } = params || {};
+ if (!projectId) {
+ return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" });
+ }
+
+ const project = await projectStoreInstance.getProject(projectId);
+ if (!project) {
+ return this.rpc.sendError(id, { code: "NOT_FOUND", message: "Project not found" });
+ }
+
+ if (!project.databaseId) {
+ return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Project has no database ID" });
+ }
+
+ // Get DB connection
+ const { conn, dbType } = await this.dbService.getDatabaseConnection(project.databaseId);
+ const dbSchema = await this.queryExecutor.listSchemas(conn, dbType) as any;
+ const liveSchemas = dbSchema.schemas;
+
+ // Generate schema hash from live schemas
+ const crypto = require("crypto");
+ const newHash = crypto.createHash("sha256").update(JSON.stringify(liveSchemas)).digest("hex");
+
+ // Fetch old schema file to compare
+ let oldHash = "";
+ try {
+ const oldSchemaFile = await projectStoreInstance.getSchema(projectId);
+ oldHash = (oldSchemaFile as any)?.schemaHash || "";
+ } catch (e) {
+ // Initial creation
+ }
+
+ // Save anyway (or only if changed, but we can always save)
+ // projectStoreInstance.saveSchema now expects to write schemaHash and dialect.
+ // Let's call saveSchema and then update the schema.json directly or update saveSchema signature.
+ // Since saveSchema only takes `schemas` right now, I'll update saveSchema in projectStore shortly.
+ await projectStoreInstance.saveSchema(projectId, liveSchemas, newHash, dbType);
+
+ if (newHash !== oldHash) {
+ this.rpc?.sendNotification?.("project.schema_changed", { projectId, newHash });
+
+ // Commit to Git if tracking
+ try {
+ const projectDir = await projectStoreInstance.resolveProjectDir(projectId);
+ if (projectDir) {
+ await gitServiceInstance.syncSchemaFile(projectDir);
+ }
+ } catch (gitErr) {
+ this.logger?.warn({ err: gitErr }, "Failed to auto-commit schema.json to Git");
+ }
+ }
+
+ this.rpc.sendResponse(id, { ok: true, hashChanged: newHash !== oldHash, newHash });
+ } catch (e: any) {
+ this.logger?.error({ e }, "project.refreshSchemaCache failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
async handleGetERDiagram(params: any, id: number | string) {
try {
const { projectId } = params || {};
@@ -247,6 +314,263 @@ export class ProjectHandlers {
}
}
+ async handleAnalyzeImport(params: any, id: number | string) {
+ try {
+ const { projectId } = params || {};
+ if (!projectId) {
+ return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" });
+ }
+
+ const project = await projectStoreInstance.getProject(projectId);
+ if (!project) {
+ return this.rpc.sendError(id, { code: "NOT_FOUND", message: "Project not found" });
+ }
+
+ // 1. Read schema snapshot
+ const schemaFile = await projectStoreInstance.getSchema(projectId);
+ const hasSchemaSnapshot = !!schemaFile && !!(schemaFile as any)?.schemas?.length;
+ const schemaHash = (schemaFile as any)?.schemaHash || "";
+
+ // 2. Read migration files from project-local dir
+ const { listMigrationFiles, readMigrationFile } = await import("../utils/migrationFileReader");
+ let migrationFiles: string[] = [];
+ let migrationsDir = "";
+ if (project.databaseId) {
+ migrationsDir = await projectStoreInstance.resolveMigrationsDir(project.databaseId);
+ migrationFiles = listMigrationFiles(migrationsDir);
+ }
+ const hasMigrations = migrationFiles.length > 0;
+
+ // 3. Read lock file
+ let lockFileStatus: "valid" | "tampered" | "missing" = "missing";
+ let tamperedFiles: string[] = [];
+ let appliedVersions: string[] = [];
+ if (project.databaseId) {
+ const { readMigrationLock } = await import("../services/migrationLock");
+ const lock = await readMigrationLock(project.databaseId);
+ if (lock) {
+ lockFileStatus = lock.schemaHash === schemaHash ? "valid" : "tampered";
+ appliedVersions = lock.appliedMigrations || [];
+ }
+ }
+
+ // 4. Query live database for table count
+ let targetDatabaseEmpty = true;
+ let targetTableCount = 0;
+ let dbAppliedVersions: string[] = [];
+ if (project.databaseId) {
+ try {
+ const { conn, dbType } = await this.dbService.getDatabaseConnection(project.databaseId);
+ let connector: any;
+ if (dbType === "mysql") connector = require("../connectors/mysql");
+ else if (dbType === "postgres") connector = require("../connectors/postgres");
+ else if (dbType === "mariadb") connector = require("../connectors/mariadb");
+ else if (dbType === "sqlite") connector = require("../connectors/sqlite");
+
+ if (connector) {
+ try {
+ const tables = await connector.listTables(conn);
+ // Filter out internal migration tracking tables
+ const userTables = (tables || []).filter((t: any) => {
+ const name = typeof t === "string" ? t : t?.name || "";
+ return name !== "__relwave_migrations" && name !== "relwave_migrations";
+ });
+ targetTableCount = userTables.length;
+ targetDatabaseEmpty = targetTableCount === 0;
+ } catch {
+ // Can't list tables — assume non-empty for safety
+ targetDatabaseEmpty = false;
+ }
+
+ try {
+ const applied = await connector.listAppliedMigrations(conn);
+ dbAppliedVersions = (applied || []).map((m: any) => m.version);
+ } catch {
+ // Migration tracking table may not exist yet
+ }
+ }
+ } catch {
+ // Database connection may not be available
+ }
+ }
+
+ // 5. Compute pending migrations
+ const appliedSet = new Set(dbAppliedVersions);
+ const pendingMigrations: Array<{
+ file: string;
+ version: string;
+ isDestructive: boolean;
+ destructiveOps: string[];
+ }> = [];
+
+ for (const file of migrationFiles) {
+ const versionMatch = file.match(/^(\d{13,14})/);
+ if (!versionMatch) continue;
+ const version = versionMatch[1];
+ if (appliedSet.has(version)) continue;
+
+ // Parse for destructive operations
+ let isDestructive = false;
+ const destructiveOps: string[] = [];
+ try {
+ const parsed = readMigrationFile(path.join(migrationsDir, file));
+ const upSQL = parsed.upSQL.toUpperCase();
+ if (upSQL.includes("DROP TABLE")) {
+ isDestructive = true;
+ destructiveOps.push("DROP TABLE");
+ }
+ if (upSQL.includes("DROP COLUMN")) {
+ isDestructive = true;
+ destructiveOps.push("DROP COLUMN");
+ }
+ if (upSQL.includes("TRUNCATE")) {
+ isDestructive = true;
+ destructiveOps.push("TRUNCATE");
+ }
+ } catch {
+ // Skip parse errors
+ }
+
+ pendingMigrations.push({ file, version, isDestructive, destructiveOps });
+ }
+
+ // 6. Detect if all migration files are baseline-only (no real user migrations)
+ const isBaselineOnly = hasMigrations && migrationFiles.every(f => f.includes("baseline"));
+
+ // 7. Compute drift status
+ let driftStatus: "synced" | "drifted" | "unknown" = "unknown";
+ if (pendingMigrations.length === 0 && hasMigrations) {
+ // All migrations applied
+ driftStatus = "synced";
+ } else if (pendingMigrations.length === 0 && !hasMigrations && !hasSchemaSnapshot) {
+ // No migrations or schema — fresh project
+ driftStatus = "synced";
+ } else if (pendingMigrations.length > 0) {
+ driftStatus = "drifted";
+ } else if (!hasMigrations && hasSchemaSnapshot && targetDatabaseEmpty) {
+ // Schema exists but database is empty — need to apply snapshot
+ driftStatus = "drifted";
+ } else if (!hasMigrations && hasSchemaSnapshot && !targetDatabaseEmpty) {
+ // Schema exists and database has tables — assume synced (user just hasn't made migrations yet)
+ driftStatus = "synced";
+ }
+
+ // 8. Compute available modes
+ const availableModes: Array<"run_migrations" | "apply_snapshot" | "skip"> = ["skip"];
+ let recommendedMode: "run_migrations" | "apply_snapshot" | "skip" = "skip";
+
+ if (isBaselineOnly && hasSchemaSnapshot && targetDatabaseEmpty) {
+ // Baseline-only + empty DB + schema.json exists:
+ // The baseline file has no real DDL — use schema.json to reconstruct the DB
+ availableModes.unshift("apply_snapshot");
+ recommendedMode = "apply_snapshot";
+ // Override: treat as "no real migrations" for the dialog
+ driftStatus = "drifted";
+ } else if (hasMigrations && pendingMigrations.length > 0 && !isBaselineOnly) {
+ availableModes.unshift("run_migrations");
+ recommendedMode = "run_migrations";
+ }
+ if (hasSchemaSnapshot && targetDatabaseEmpty && !hasMigrations) {
+ availableModes.unshift("apply_snapshot");
+ recommendedMode = "apply_snapshot";
+ }
+
+ // For the dialog: if baseline-only + empty DB, report as "no real migrations"
+ // so the frontend shows "Apply Schema Snapshot" (STATE 3) instead of
+ // "Pending Migrations" (STATE 2) which would try to run the empty baseline
+ const reportHasMigrations = isBaselineOnly && targetDatabaseEmpty ? false : hasMigrations;
+ const reportPendingMigrations = isBaselineOnly && targetDatabaseEmpty ? [] : pendingMigrations;
+
+ const result = {
+ hasMigrations: reportHasMigrations,
+ migrationCount: reportPendingMigrations.length,
+ hasSchemaSnapshot,
+ lockFileStatus,
+ tamperedFiles,
+ targetDatabaseEmpty,
+ targetTableCount,
+ driftStatus,
+ driftDetails: undefined,
+ pendingMigrations: reportPendingMigrations,
+ availableModes,
+ recommendedMode,
+ };
+
+ this.rpc.sendResponse(id, { ok: true, data: result });
+ } catch (e: any) {
+ this.logger?.error({ e }, "project.analyzeImport failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
+ async handleVerifyLock(params: any, id: number | string) {
+ try {
+ const { projectId } = params || {};
+ if (!projectId) {
+ return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" });
+ }
+ const project = await projectStoreInstance.getProject(projectId);
+ if (!project?.databaseId) {
+ return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Project has no database ID" });
+ }
+ const { verifyMigrationLock } = await import("../services/migrationLock");
+ const schemaFile = await projectStoreInstance.getSchema(projectId);
+ const schemaHash = (schemaFile as any)?.schemaHash || "";
+
+ const isValid = await verifyMigrationLock(project.databaseId, schemaHash);
+ this.rpc.sendResponse(id, { ok: true, isValid });
+ } catch (e: any) {
+ this.logger?.error({ e }, "project.verifyLock failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
+ async handlePushMigrations(params: any, id: number | string) {
+ try {
+ const { projectId } = params || {};
+ if (!projectId) return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" });
+ const projectDir = await projectStoreInstance.resolveProjectDir(projectId);
+ if (!projectDir) return this.rpc.sendError(id, { code: "NOT_FOUND", message: "Project directory not found" });
+
+ const { gitServiceInstance } = await import("../services/gitService");
+ await gitServiceInstance.pushMigrations(projectDir);
+ this.rpc.sendResponse(id, { ok: true });
+ } catch (e: any) {
+ this.logger?.error({ e }, "project.pushMigrations failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
+ async handleSyncMigrations(params: any, id: number | string) {
+ try {
+ const { projectId } = params || {};
+ if (!projectId) return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" });
+ const projectDir = await projectStoreInstance.resolveProjectDir(projectId);
+ if (!projectDir) return this.rpc.sendError(id, { code: "NOT_FOUND", message: "Project directory not found" });
+
+ const { gitServiceInstance } = await import("../services/gitService");
+ const result = await gitServiceInstance.syncMigrationFiles(projectDir);
+ if (result.error) throw new Error(result.error.message || String(result.error));
+ this.rpc.sendResponse(id, { ok: true, data: result });
+ } catch (e: any) {
+ this.logger?.error({ e }, "project.syncMigrations failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
+ async handleGetDrift(params: any, id: number | string) {
+ try {
+ const { projectId } = params || {};
+ if (!projectId) return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" });
+
+ // Stub implementation for now
+ this.rpc.sendResponse(id, { ok: true, data: { driftDetected: false, differences: [] } });
+ } catch (e: any) {
+ this.logger?.error({ e }, "project.getDrift failed");
+ this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
+ }
+ }
+
async handleGetQueries(params: any, id: number | string) {
try {
const { projectId } = params || {};
@@ -430,7 +754,7 @@ export class ProjectHandlers {
this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) });
}
}
-
+
/** Read-only scan — returns metadata + .env info without creating anything. */
async handleScanImport(params: any, id: number | string) {
try {
@@ -503,4 +827,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 55babca..9b0dc9a 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,24 @@ export function registerDbHandlers(
rpcRegister(rpc, "project.saveAnnotations", (p, id) =>
projectHandlers.handleSaveAnnotations(p, id)
);
+ rpcRegister(rpc, "project.analyzeImport", (p, id) =>
+ projectHandlers.handleAnalyzeImport(p, id)
+ );
+ rpcRegister(rpc, "project.verifyLock", (p, id) =>
+ projectHandlers.handleVerifyLock(p, id)
+ );
+ rpcRegister(rpc, "project.pushMigrations", (p, id) =>
+ projectHandlers.handlePushMigrations(p, id)
+ );
+ rpcRegister(rpc, "project.syncMigrations", (p, id) =>
+ projectHandlers.handleSyncMigrations(p, id)
+ );
+ rpcRegister(rpc, "project.generateSQL", (p, id) =>
+ projectHandlers.handleGenerateSQL(p, id)
+ );
+ rpcRegister(rpc, "project.getDrift", (p, id) =>
+ projectHandlers.handleGetDrift(p, id)
+ );
rpcRegister(rpc, "project.getQueries", (p, id) =>
projectHandlers.handleGetQueries(p, id)
);
diff --git a/bridge/src/queries/README.md b/bridge/src/queries/README.md
new file mode 100644
index 0000000..c8ad53f
--- /dev/null
+++ b/bridge/src/queries/README.md
@@ -0,0 +1,26 @@
+# SQL Queries (Bridge)
+
+This folder contains centralized SQL query strings and SQL helper functions for supported database engines.
+
+Layout
+- `index.ts` - central export for all query modules.
+- `postgres/` - PostgreSQL SQL grouped by schema, tables, constraints, stats, migrations and CRUD.
+- `mysql/` - MySQL SQL grouped by schema, tables, columns, constraints, stats, migrations and CRUD.
+- `sqlite/` - SQLite SQL grouped by schema, tables, constraints, stats, migrations and CRUD.
+
+How it fits
+- Connectors import query constants from this folder and bind parameters through their database driver.
+- Query modules keep SQL text out of service and handler code.
+- Database-specific quoting helpers, such as CRUD identifier quoting, live beside the SQL for that engine.
+
+How to add a query
+1. Add the SQL constant or helper to the matching database folder.
+2. Export it from that database folder's `index.ts` if other modules need central imports.
+3. Use parameter placeholders supported by the target driver.
+4. Keep result column aliases compatible with `src/types/common.ts` where possible.
+5. Add database-specific types under `src/types` only when the common shape is not enough.
+
+Notes
+- Do not concatenate user-controlled identifiers or values directly into SQL. Use driver parameters for values and vetted quote helpers for identifiers.
+- Keep equivalent queries across PostgreSQL, MySQL/MariaDB and SQLite aligned so `QueryExecutor` can return consistent frontend shapes.
+- If a query intentionally excludes system schemas or internal tables, document that behavior near the SQL constant.
diff --git a/bridge/src/queries/mysql/columns.ts b/bridge/src/queries/mysql/columns.ts
index 903d16c..d112632 100644
--- a/bridge/src/queries/mysql/columns.ts
+++ b/bridge/src/queries/mysql/columns.ts
@@ -16,7 +16,11 @@ export const BATCH_GET_ALL_COLUMNS = `
c.ORDINAL_POSITION AS ordinal_position,
c.CHARACTER_MAXIMUM_LENGTH AS max_length,
(c.COLUMN_KEY = 'PRI') AS is_primary_key,
- CASE WHEN fk.COLUMN_NAME IS NOT NULL THEN TRUE ELSE FALSE END AS is_foreign_key
+ CASE WHEN fk.COLUMN_NAME IS NOT NULL THEN TRUE ELSE FALSE END AS is_foreign_key,
+ (c.COLUMN_KEY = 'UNI' OR c.COLUMN_KEY = 'PRI') AS is_unique,
+ (c.EXTRA LIKE '%auto_increment%') AS is_serial,
+ c.COLUMN_COMMENT AS comment,
+ NULL AS check_constraint
FROM information_schema.columns c
LEFT JOIN (
SELECT DISTINCT kcu.TABLE_SCHEMA, kcu.TABLE_NAME, kcu.COLUMN_NAME
diff --git a/bridge/src/queries/postgres/tables.ts b/bridge/src/queries/postgres/tables.ts
index a3fa7fa..423019a 100644
--- a/bridge/src/queries/postgres/tables.ts
+++ b/bridge/src/queries/postgres/tables.ts
@@ -28,8 +28,7 @@ export const PG_GET_TABLE_DETAILS = `
`;
/**
- * Get all columns in a schema (batch query)
- * @param schemaName - Use $1 placeholder
+ * Batch get all columns for all tables in a schema
*/
export const PG_BATCH_GET_ALL_COLUMNS = `
SELECT
@@ -56,7 +55,27 @@ export const PG_BATCH_GET_ALL_COLUMNS = `
AND tc.table_name = c.table_name
AND kcu.column_name = c.column_name),
FALSE
- ) AS is_foreign_key
+ ) AS is_foreign_key,
+ COALESCE(
+ (SELECT TRUE FROM information_schema.table_constraints tc
+ JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
+ WHERE tc.constraint_type = 'UNIQUE'
+ AND tc.table_schema = c.table_schema
+ AND tc.table_name = c.table_name
+ AND kcu.column_name = c.column_name),
+ FALSE
+ ) AS is_unique,
+ (c.column_default ILIKE '%nextval%' OR c.is_identity = 'YES') AS is_serial,
+ (SELECT pg_catalog.col_description(format('%I.%I', c.table_schema, c.table_name)::regclass::oid, c.ordinal_position)) AS comment,
+ (SELECT cc.check_clause
+ FROM information_schema.table_constraints tc
+ JOIN information_schema.check_constraints cc ON tc.constraint_name = cc.constraint_name
+ JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
+ WHERE tc.constraint_type = 'CHECK'
+ AND tc.table_schema = c.table_schema
+ AND tc.table_name = c.table_name
+ AND ccu.column_name = c.column_name
+ LIMIT 1) AS check_constraint
FROM information_schema.columns c
WHERE c.table_schema = $1
ORDER BY c.table_name, c.ordinal_position;
diff --git a/bridge/src/services/README.md b/bridge/src/services/README.md
new file mode 100644
index 0000000..b248036
--- /dev/null
+++ b/bridge/src/services/README.md
@@ -0,0 +1,37 @@
+# Services (Bridge)
+
+This folder contains bridge business logic and persistence services. Services sit between RPC handlers and low-level connectors, stores, keyrings, Git, filesystem and monitoring utilities.
+
+Layout
+- `databaseService.ts` - database metadata lifecycle, credential lookup and connection config retrieval.
+- `queryExecutor.ts` - database-agnostic dispatcher for schema, table, query, CRUD and migration operations.
+- `connectionBuilder.ts` - builds driver config objects and optional SSH tunnel wiring.
+- `connectionPool.ts` - caches resolved connection configs and tunnel handles by database id.
+- `connectorRegistry.ts` - maps database types to connector implementations.
+- `dbStore.ts` - persisted database connection metadata and credential references.
+- `projectStore.ts` - project files, schema snapshots, ER diagrams, annotations, saved queries and local project config.
+- `gitService.ts` - local Git operations used by Git handlers.
+- `keyringService.ts` - encrypted credential storage through `@napi-rs/keyring`.
+- `sshTunnelService.ts` - SSH tunnel creation and cleanup.
+- `discoveryService.ts` - local database discovery.
+- `monitoringService.ts` and `monitoringWebSocketServer.ts` - database monitoring snapshots and websocket broadcast support.
+- `ai.impl.ts`, `aiService.ts`, `aiCacheService.ts`, `aiHistoryStore.ts` - AI service factory, compatibility shim, cache and history persistence.
+- `logger.ts` - shared pino logger.
+
+How it fits
+- Handlers call services to do real work; services should not know about UI components.
+- Services call connectors, stores and utilities, then return plain data to handlers.
+- Persistent paths are defined in `src/utils/config.ts`.
+- Shared types come from `src/types`.
+
+How to add service behavior
+1. Put durable business logic here when it is shared by multiple handlers or has state/persistence concerns.
+2. Keep public methods small and typed enough for handler use.
+3. Let handlers translate thrown errors into JSON-RPC errors.
+4. Invalidate connection or metadata caches when mutating database/project state.
+5. Add focused tests under `bridge/__tests__` for services with persistence, Git, credential or connector logic.
+
+Notes
+- Keep secrets out of logs and return values.
+- Avoid long-lived open database sockets unless the connection pool explicitly owns them.
+- When adding filesystem writes, use paths derived from `src/utils/config.ts` so RelWave respects `RELWAVE_HOME`.
diff --git a/bridge/src/services/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 = {};
diff --git a/bridge/src/services/gitService.ts b/bridge/src/services/gitService.ts
index f96c04e..fbf5b5f 100644
--- a/bridge/src/services/gitService.ts
+++ b/bridge/src/services/gitService.ts
@@ -87,6 +87,23 @@ export interface GitBranchInfo {
upstream: string | null;
}
+export type GitSyncResult = {
+ staged: string[];
+ commitHash: string;
+ commitMessage: string;
+ pushed: boolean;
+ error?: { code: string; message: string };
+};
+
+export class GitError extends Error {
+ code: string;
+ constructor(code: string, message: string) {
+ super(message);
+ this.code = code;
+ this.name = "GitError";
+ }
+}
+
export class GitService {
/**
* Run a git command in a specific directory.
@@ -1088,6 +1105,205 @@ export class GitService {
async renameBranch(dir: string, newName: string): Promise {
await this.git(dir, "branch", "-m", newName);
}
+
+ // ==========================================
+ // Migration Sync (Task 1)
+ // ==========================================
+
+ /**
+ * Stage migration files and lock file
+ */
+ async stageMigrationFiles(projectPath: string): Promise {
+ // Run git add on migrations/ and migration-lock.json
+ // But first check if they exist or have changes
+ const filesToStage = ["migrations"];
+ const stagedPaths: string[] = [];
+
+ for (const file of filesToStage) {
+ try {
+ await this.git(projectPath, "add", file);
+ stagedPaths.push(file);
+ } catch (err) {
+ // Ignore if path doesn't exist
+ }
+ }
+
+ // Return actually staged files from status
+ const changes = await this.getChangedFiles(projectPath);
+ return changes.filter(c => c.staged).map(c => c.path);
+ }
+
+ /**
+ * Commit migration files with an auto-generated or provided message
+ */
+ async commitMigrationFiles(projectPath: string, message?: string): Promise {
+ let commitMsg = message;
+
+ if (!commitMsg) {
+ // Auto-generate message based on staged files
+ const changes = await this.getChangedFiles(projectPath);
+ const stagedMigrations = changes.filter(c => c.staged && c.path.startsWith("migrations/"));
+
+ if (stagedMigrations.length === 1) {
+ const filename = path.basename(stagedMigrations[0].path);
+ if (filename.includes("baseline")) {
+ commitMsg = "migrate: add baseline migration from schema snapshot";
+ } else {
+ commitMsg = `migrate: apply ${filename}`;
+ }
+ } else if (stagedMigrations.length > 1) {
+ // Find highest migration
+ const sorted = stagedMigrations.map(m => path.basename(m.path)).sort();
+ const latest = sorted[sorted.length - 1];
+ commitMsg = `migrate: apply ${stagedMigrations.length} migrations up to ${latest}`;
+ } else {
+ commitMsg = "migrate: update schema lock";
+ }
+ }
+
+ return this.commit(projectPath, commitMsg);
+ }
+
+ /**
+ * Push current branch to remote
+ */
+ async pushMigrations(projectPath: string): Promise {
+ const remotes = await this.remoteList(projectPath);
+ if (remotes.length === 0) {
+ throw new GitError("NO_REMOTE", "No remote configured for this repository");
+ }
+
+ try {
+ await this.git(projectPath, "push");
+ } catch (err: any) {
+ const errorMsg = err.stderr || err.message || "";
+ if (errorMsg.includes("non-fast-forward") || errorMsg.includes("fetch first")) {
+ throw new GitError("PUSH_REJECTED", "Push rejected due to diverged history");
+ }
+ throw new GitError("PUSH_FAILED", `Push failed: ${errorMsg}`);
+ }
+ }
+
+ /**
+ * Orchestrate staging, committing, and pushing
+ */
+ async syncMigrationFiles(projectPath: string, message?: string): Promise {
+ const result: GitSyncResult = {
+ staged: [],
+ commitHash: "",
+ commitMessage: "",
+ pushed: false,
+ };
+
+ try {
+ result.staged = await this.stageMigrationFiles(projectPath);
+ if (result.staged.length === 0) {
+ return result; // Nothing to sync
+ }
+
+ result.commitHash = await this.commitMigrationFiles(projectPath, message);
+
+ // Get actual message used if auto-generated
+ if (!message && result.commitHash) {
+ const log = await this.log(projectPath, 1);
+ if (log.length > 0) result.commitMessage = log[0].subject;
+ } else {
+ result.commitMessage = message || "";
+ }
+
+ // Read config to see if autoPushMigrations is true
+ let autoPush = false;
+ try {
+ const configPath = path.join(projectPath, "relwave.json");
+ if (fsSync.existsSync(configPath)) {
+ const config = JSON.parse(fsSync.readFileSync(configPath, "utf-8"));
+ if (config.autoPushMigrations === true) {
+ autoPush = true;
+ }
+ }
+ } catch (err) {
+ // Ignore config read errors
+ }
+
+ if (autoPush) {
+ try {
+ await this.pushMigrations(projectPath);
+ result.pushed = true;
+ } catch (pushErr: any) {
+ result.error = {
+ code: pushErr.code || "PUSH_FAILED",
+ message: pushErr.message
+ };
+ }
+ }
+
+ } catch (err: any) {
+ result.error = {
+ code: err.code || "SYNC_FAILED",
+ message: err.message
+ };
+ }
+
+ return result;
+ }
+
+ /**
+ * Stash, sync schema.json
+ */
+ async syncSchemaFile(projectPath: string, message?: string): Promise {
+ try {
+ await this.ensureGitignore(projectPath);
+ const changes = await this.getChangedFiles(projectPath);
+ const schemaFile = "schema/schema.json";
+
+ const schemaModified = changes.some(c => c.path === schemaFile);
+
+ if (!schemaModified) {
+ return { staged: [], commitHash: "", commitMessage: "", pushed: false };
+ }
+
+ await this.stageFiles(projectPath, [schemaFile]);
+
+ const commitMessage = message || `chore: update schema.json cache`;
+ const commitHash = await this.commit(projectPath, commitMessage);
+
+ let pushed = false;
+ try {
+ await this.pushMigrations(projectPath);
+ pushed = true;
+ } catch (pushErr: any) {
+ // Return result with push error but still considered successful commit
+ return {
+ staged: [schemaFile],
+ commitHash,
+ commitMessage,
+ pushed: false,
+ error: {
+ code: "GIT_PUSH_FAILED",
+ message: `Committed successfully but failed to push: ${pushErr.message || String(pushErr)}`
+ }
+ };
+ }
+
+ return {
+ staged: [schemaFile],
+ commitHash,
+ commitMessage,
+ pushed
+ };
+ } catch (error: any) {
+ return {
+ staged: [],
+ commitHash: "",
+ commitMessage: "",
+ pushed: false,
+ error: {
+ code: "GIT_SYNC_FAILED",
+ message: `Failed to sync schema file: ${error.message || String(error)}`
+ }
+ };
+ }
+ }
}
export const gitServiceInstance = new GitService();
diff --git a/bridge/src/services/migrationLock.ts b/bridge/src/services/migrationLock.ts
new file mode 100644
index 0000000..efa358b
--- /dev/null
+++ b/bridge/src/services/migrationLock.ts
@@ -0,0 +1,51 @@
+import fs from "fs/promises";
+import path from "path";
+import fsSync from "fs";
+import { projectStoreInstance } from "./projectStore";
+
+export interface MigrationLock {
+ version: string;
+ schemaHash: string;
+ appliedMigrations: string[]; // List of migration filenames
+ updatedAt: string;
+}
+
+export async function getLockFilePath(dbId: string): Promise {
+ const migrationsDir = await projectStoreInstance.resolveMigrationsDir(dbId);
+ return path.join(migrationsDir, "migration.lock.json");
+}
+
+export async function readMigrationLock(dbId: string): Promise {
+ const lockPath = await getLockFilePath(dbId);
+ if (!fsSync.existsSync(lockPath)) return null;
+
+ try {
+ const raw = await fs.readFile(lockPath, "utf8");
+ return JSON.parse(raw) as MigrationLock;
+ } catch (err) {
+ return null;
+ }
+}
+
+export async function writeMigrationLock(
+ dbId: string,
+ schemaHash: string,
+ appliedMigrations: string[]
+): Promise {
+ const lockPath = await getLockFilePath(dbId);
+ const lockData: MigrationLock = {
+ version: "1",
+ schemaHash,
+ appliedMigrations,
+ updatedAt: new Date().toISOString()
+ };
+
+ await fs.mkdir(path.dirname(lockPath), { recursive: true }).catch(() => {});
+ await fs.writeFile(lockPath, JSON.stringify(lockData, null, 2), "utf8");
+}
+
+export async function verifyMigrationLock(dbId: string, targetHash: string): Promise {
+ const lock = await readMigrationLock(dbId);
+ if (!lock) return false;
+ return lock.schemaHash === targetHash;
+}
diff --git a/bridge/src/services/projectStore.ts b/bridge/src/services/projectStore.ts
index 79c93c2..3ed948d 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" | "unknown";
schemas: SchemaSnapshot[];
cachedAt: string;
+ relwaveVersion: string;
+ schemaHash: string; // SHA-256 of entire schema content
};
export type SchemaSnapshot = {
name: string;
tables: TableSnapshot[];
+ enums?: EnumSnapshot[];
+ views?: ViewSnapshot[];
};
export type TableSnapshot = {
name: string;
type: string;
columns: ColumnSnapshot[];
+ indexes: IndexSnapshot[];
+ foreignKeys: ForeignKeySnapshot[];
+ checks: CheckConstraint[];
+ comment?: string | null;
};
export type ColumnSnapshot = {
@@ -100,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<
@@ -339,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
@@ -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,
@@ -509,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
@@ -572,6 +627,66 @@ 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 || "";
+
+ // Read lock file
+ let hasLock = false;
+ let hashMatch = false;
+ let migrationsCount = 0;
+
+ try {
+ const project = await this.getProject(projectId);
+ if (project?.databaseId) {
+ const { readMigrationLock } = await import("./migrationLock");
+ const lock = await readMigrationLock(project.databaseId);
+ if (lock) {
+ hasLock = true;
+ hashMatch = lock.schemaHash === schemaHash;
+ migrationsCount = lock.appliedMigrations.length;
+ }
+ }
+ } catch (e) {
+ // Ignore if lock file logic fails
+ }
+
+ return { hasLock, hashMatch, migrationsCount };
+ }
+
async deleteProject(projectId: string): Promise {
await this.ensureSourcePathCache();
const isImported = this.sourcePathCache.has(projectId);
@@ -592,33 +707,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);
}
- async saveSchema(projectId: string, schemas: SchemaSnapshot[]): Promise {
+ private migrateSchemaFile(raw: any): SchemaFile {
+ if (raw.version >= 2) return raw as SchemaFile;
+
+ // Upgrade v1 to v2
+ return {
+ version: 2,
+ projectId: raw.projectId,
+ databaseId: raw.databaseId,
+ dialect: "postgresql", // assume postgresql for legacy files
+ cachedAt: raw.cachedAt || new Date().toISOString(),
+ relwaveVersion: "1.0.0",
+ schemaHash: "", // will be recomputed on next refresh
+ schemas: (raw.schemas || []).map((s: any) => ({
+ name: s.name,
+ tables: (s.tables || []).map((t: any) => ({
+ name: t.name,
+ type: t.type || "BASE TABLE",
+ comment: null,
+ columns: (t.columns || []).map((c: any, index: number) => {
+ const isSerial = c.defaultValue ? c.defaultValue.includes("nextval") : false;
+ return {
+ ...c,
+ isSerial,
+ ordinalPosition: index + 1,
+ };
+ }),
+ indexes: [],
+ foreignKeys: [],
+ checks: [],
+ })),
+ enums: [],
+ views: [],
+ })),
+ };
+ }
+
+ async saveSchema(
+ projectId: string,
+ schemas: SchemaSnapshot[],
+ schemaHash?: string,
+ dialect?: string
+ ): Promise {
const meta = await this.getProject(projectId);
if (!meta) throw new Error(`Project ${projectId} not found`);
// Read existing file and skip write if schema data is identical
// (avoids cachedAt churn that creates phantom git changes)
const existing = await this.getSchema(projectId);
- if (existing) {
+ if (existing && existing.version === 2) {
const oldData = JSON.stringify(existing.schemas);
const newData = JSON.stringify(schemas);
- if (oldData === newData) {
+ // If schemaHash is provided, also check if it matches
+ if (oldData === newData && (!schemaHash || existing.schemaHash === schemaHash)) {
return existing; // nothing changed — keep old cachedAt
}
}
const now = new Date().toISOString();
const file: SchemaFile = {
- version: 1,
+ version: 2,
projectId,
databaseId: meta.databaseId,
+ dialect: (dialect || (meta.engine === "postgresql" ? "postgresql" : (meta.engine === "mysql" || meta.engine === "mariadb") ? "mysql" : "sqlite")) as any,
schemas,
cachedAt: now,
+ relwaveVersion: "1.0.0",
+ schemaHash: schemaHash || "",
};
await this.writeJSON(
@@ -1222,7 +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/services/queryExecutor.ts b/bridge/src/services/queryExecutor.ts
index e098e61..425d58a 100644
--- a/bridge/src/services/queryExecutor.ts
+++ b/bridge/src/services/queryExecutor.ts
@@ -39,7 +39,12 @@ function mapColumn(col: any) {
isPrimaryKey: col.is_primary_key === true,
isForeignKey: col.is_foreign_key === true,
defaultValue: col.default_value || null,
- isUnique: false,
+ isUnique: col.is_unique === true,
+ isSerial: col.is_serial === true,
+ checkConstraint: col.check_constraint || null,
+ comment: col.comment || null,
+ maxLength: col.max_length || null,
+ ordinalPosition: col.ordinal_position || 0,
};
}
diff --git a/bridge/src/types/README.md b/bridge/src/types/README.md
new file mode 100644
index 0000000..5722e12
--- /dev/null
+++ b/bridge/src/types/README.md
@@ -0,0 +1,28 @@
+# Types (Bridge)
+
+This folder contains TypeScript types shared across bridge handlers, services, connectors and query result mapping.
+
+Layout
+- `index.ts` - central export plus `DBType`, `Rpc`, `DatabaseConfig` and `QueryParams`.
+- `common.ts` - shared database metadata shapes such as tables, columns, keys, indexes, constraints, stats, migrations and SSH config.
+- `postgres.ts` - PostgreSQL config and PostgreSQL-specific metadata/DDL types.
+- `mysql.ts` - MySQL and MariaDB config and metadata types.
+- `sqlite.ts` - SQLite config and metadata types.
+- `cache.ts` - cache entry and TTL types used by connector caches.
+- `ai.ts` - AI provider, prompt, result, history and error types.
+
+How it fits
+- Connectors use these types for database-specific configs and normalized result shapes.
+- Services use these types to avoid duplicating payload contracts.
+- Handlers should return data that matches these types where possible, then frontend bridge services mirror the same shape.
+
+How to add or change types
+1. Prefer extending `common.ts` when a shape applies across database engines.
+2. Use database-specific files only for engine-specific fields or operations.
+3. Re-export new public types from `index.ts`.
+4. Update connector and frontend types together when changing RPC response shapes.
+
+Notes
+- Keep these files type-only. Runtime helpers belong in `src/utils` or `src/services`.
+- Avoid widening everything to `any`; use explicit optional fields when a database can omit metadata.
+- Be careful with credential-bearing types. Public response shapes should not include plaintext passwords.
diff --git a/bridge/src/types/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/README.md b/bridge/src/utils/README.md
new file mode 100644
index 0000000..d5a2392
--- /dev/null
+++ b/bridge/src/utils/README.md
@@ -0,0 +1,27 @@
+# Utilities (Bridge)
+
+This folder contains small, reusable bridge helpers for paths, database type detection, migration file handling and configuration.
+
+Layout
+- `config.ts` - RelWave data directories and file paths. Honors `RELWAVE_HOME` when set.
+- `dbTypeDetector.ts` - maps saved database records to `DBType`.
+- `sqlitePath.ts` - SQLite path normalization and resolution helpers.
+- `migrationGenerator.ts` - SQL migration file generation for create, alter and drop flows.
+- `migrationFileReader.ts` - migration file parsing/reading helpers.
+- `baselineMigration.ts` - local baseline migration loading and writing helpers.
+
+How it fits
+- Services and connectors use these helpers for filesystem paths, database type decisions and migration workflows.
+- `config.ts` is the source of truth for bridge persistence locations, including databases, projects, migrations and connection folders.
+- Migration utilities are used by migration handlers and connectors to generate, read and reconcile local migration files.
+
+How to add a utility
+1. Add utilities here only when they are reusable and do not own business state.
+2. Keep functions deterministic where possible and pass dependencies in as parameters.
+3. Use `config.ts` path helpers for filesystem locations instead of hardcoding OS-specific paths.
+4. Add tests for parsing, path resolution or SQL generation behavior that can regress quietly.
+
+Notes
+- Utilities should not send JSON-RPC responses or import frontend code.
+- Keep database-specific SQL in `src/queries`; use utility helpers for generation/parsing logic that is shared by workflows.
+- Avoid storing secrets in files managed by utility paths. Credentials should go through `keyringService`.
diff --git a/bridge/src/utils/baselineMigration.ts b/bridge/src/utils/baselineMigration.ts
index 03d3867..291363e 100644
--- a/bridge/src/utils/baselineMigration.ts
+++ b/bridge/src/utils/baselineMigration.ts
@@ -1,14 +1,124 @@
import fs from "fs";
import path from "path";
-function generateBaselineSQL(version: string, name: string) {
- return `-- ${version}_${name}.sql
+import { SchemaFile } from "../services/projectStore";
+
+function quoteIdent(name: string, dbType: string): string {
+ if (dbType === "mysql" || dbType === "mariadb") {
+ return `\`${name.replace(/`/g, "``")}\``;
+ } else {
+ return `"${name.replace(/"/g, '""')}"`;
+ }
+}
+
+export function generateBaselineSQL(snapshot: SchemaFile, version: string, name: string) {
+ const dbType = snapshot.dialect || "unknown";
+
+ if (dbType === "unknown") {
+ return `-- ${version}_${name}.sql
-- +up
-- Baseline migration
-- Existing schema assumed to be correct
-- No-op
+-- +down
+-- Rollback not supported for baseline
+`;
+ }
+
+ let upSQL = "";
+
+ for (const schema of snapshot.schemas) {
+ // Enums (Postgres)
+ if (schema.enums && dbType === "postgresql") {
+ for (const e of schema.enums) {
+ const vals = e.values.map(v => `'${v.replace(/'/g, "''")}'`).join(", ");
+ upSQL += `CREATE TYPE ${quoteIdent(schema.name, dbType)}.${quoteIdent(e.name, dbType)} AS ENUM (${vals});\n`;
+ }
+ if (schema.enums.length > 0) upSQL += "\n";
+ }
+
+ // Tables
+ for (const table of schema.tables) {
+ // Skip internal migration tracking tables
+ if (["schema_migrations", "__relwave_migrations", "relwave_migrations"].includes(table.name)) {
+ continue;
+ }
+
+ const tableRef = dbType === "sqlite" ? quoteIdent(table.name, dbType) : `${quoteIdent(schema.name, dbType)}.${quoteIdent(table.name, dbType)}`;
+
+ // Check for sequences first (Postgres)
+ if (dbType === "postgresql") {
+ for (const col of table.columns) {
+ if (col.defaultValue && col.defaultValue.includes("nextval(")) {
+ // Extract 'windows_store_apps_id_seq' from nextval('windows_store_apps_id_seq'::regclass)
+ const seqMatch = col.defaultValue.match(/nextval\('([^']+)'/i) || col.defaultValue.match(/nextval\("([^"]+)"/i);
+ if (seqMatch && seqMatch[1]) {
+ const seqName = seqMatch[1];
+ const isSchemaQualified = seqName.includes(".");
+ const seqRef = isSchemaQualified ? seqName : `${quoteIdent(schema.name, dbType)}.${quoteIdent(seqName, dbType)}`;
+ upSQL += `CREATE SEQUENCE IF NOT EXISTS ${seqRef};\n`;
+ }
+ }
+ }
+ }
+
+ const columnDefs = table.columns.map(col => {
+ let def = ` ${quoteIdent(col.name, dbType)} ${col.type}`;
+ if (!col.nullable) def += " NOT NULL";
+ if (col.defaultValue) def += ` DEFAULT ${col.defaultValue}`;
+ if (col.isPrimaryKey) def += " PRIMARY KEY";
+ if (col.isUnique && !col.isPrimaryKey) def += " UNIQUE";
+ if (col.checkConstraint) def += ` ${col.checkConstraint}`;
+ return def;
+ });
+
+ // check constraints
+ if (table.checks && table.checks.length > 0) {
+ for (const chk of table.checks) {
+ columnDefs.push(` CONSTRAINT ${quoteIdent(chk.name, dbType)} CHECK (${chk.expression})`);
+ }
+ }
+
+ const fkDefs = (table.foreignKeys || []).map(fk => {
+ const onDelete = fk.onDelete || "NO ACTION";
+ const onUpdate = fk.onUpdate || "NO ACTION";
+ const targetRef = dbType === "sqlite" ? quoteIdent(fk.referencedTable, dbType) : `${quoteIdent(fk.referencedSchema || schema.name, dbType)}.${quoteIdent(fk.referencedTable, dbType)}`;
+ const srcCols = fk.columns.map(c => quoteIdent(c, dbType)).join(", ");
+ const tgtCols = fk.referencedColumns.map(c => quoteIdent(c, dbType)).join(", ");
+ return ` CONSTRAINT ${quoteIdent(fk.name, dbType)} FOREIGN KEY (${srcCols}) REFERENCES ${targetRef}(${tgtCols}) ON DELETE ${onDelete} ON UPDATE ${onUpdate}`;
+ });
+
+ const allDefs = [...columnDefs, ...fkDefs].join(",\n");
+
+ upSQL += `CREATE TABLE ${tableRef} (\n${allDefs}\n);\n\n`;
+
+ // Indexes
+ const indexesToCreate = (table.indexes || []);
+ for (const idx of indexesToCreate) {
+ const idxCols = idx.columns.map(c => quoteIdent(c, dbType)).join(", ");
+ const uniqueStr = idx.unique ? "UNIQUE " : "";
+ upSQL += `CREATE ${uniqueStr}INDEX ${quoteIdent(idx.name, dbType)} ON ${tableRef} (${idxCols});\n`;
+ }
+ if (indexesToCreate.length > 0) upSQL += "\n";
+ }
+
+ // Views
+ if (schema.views) {
+ for (const v of schema.views) {
+ const viewRef = dbType === "sqlite" ? quoteIdent(v.name, dbType) : `${quoteIdent(schema.name, dbType)}.${quoteIdent(v.name, dbType)}`;
+ upSQL += `CREATE VIEW ${viewRef} AS ${v.definition};\n\n`;
+ }
+ }
+ }
+
+ return `-- ${version}_${name}.sql
+
+-- +up
+-- Baseline migration generated from current schema snapshot
+${upSQL.trim()}
+
-- +down
-- Rollback not supported for baseline
`;
@@ -18,7 +128,8 @@ function generateBaselineSQL(version: string, name: string) {
export function writeBaselineMigration(
migrationsDir: string,
version: string,
- name: string
+ name: string,
+ snapshot: SchemaFile
) {
if (!fs.existsSync(migrationsDir)) {
fs.mkdirSync(migrationsDir, { recursive: true });
@@ -27,7 +138,7 @@ export function writeBaselineMigration(
const filename = `${version}_${name}.sql`;
const filepath = path.join(migrationsDir, filename);
- const sql = generateBaselineSQL(version, name);
+ const sql = generateBaselineSQL(snapshot, version, name);
fs.writeFileSync(filepath, sql, "utf8");
return filepath;
diff --git a/bridge/src/utils/config.ts b/bridge/src/utils/config.ts
index c1435ec..f650257 100644
--- a/bridge/src/utils/config.ts
+++ b/bridge/src/utils/config.ts
@@ -22,10 +22,6 @@ export function getConnectionDir(connectionId: string) {
return path.join(CONFIG_FOLDER, "connections", connectionId);
}
-export function getMigrationsDir(connectionId: string) {
- return path.join(CONFIG_FOLDER, "migrations", connectionId);
-}
-
export function getProjectDir(projectId: string) {
return path.join(PROJECTS_FOLDER, projectId);
}
diff --git a/bridge/src/utils/migrationFileReader.ts b/bridge/src/utils/migrationFileReader.ts
index ac5a07e..88627fc 100644
--- a/bridge/src/utils/migrationFileReader.ts
+++ b/bridge/src/utils/migrationFileReader.ts
@@ -17,8 +17,8 @@ export function readMigrationFile(filepath: string): ParsedMigration {
const content = fs.readFileSync(filepath, "utf8");
const filename = path.basename(filepath);
- // Extract version and name from filename: 20260104160000_create_users.sql
- const match = filename.match(/^(\d{14})_(.+)\.sql$/);
+ // Accept both 14-digit (YYYYMMDDHHmmss) and 13-digit (Date.now() ms) version formats
+ const match = filename.match(/^(\d{13,14})_(.+)\.sql$/);
if (!match) {
throw new Error(`Invalid migration filename format: ${filename}`);
}
diff --git a/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/components/ui/SQLPreviewSheet.tsx b/src/components/ui/SQLPreviewSheet.tsx
new file mode 100644
index 0000000..91eb835
--- /dev/null
+++ b/src/components/ui/SQLPreviewSheet.tsx
@@ -0,0 +1,72 @@
+import { Check, Copy, X } from "lucide-react";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { cn } from "@/lib/utils";
+
+interface SQLPreviewSheetProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ sql: string;
+ title?: string;
+}
+
+export default function SQLPreviewSheet({
+ open,
+ onOpenChange,
+ sql,
+ title = "SQL Preview",
+}: SQLPreviewSheetProps) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(sql);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+
+
+
+
+
+
+ {title}
+
+
+ Preview the SQL that will be executed.
+
+
+
+
+ {copied ? : }
+ {copied ? "Copied" : "Copy"}
+
+
+
+
+ Close
+
+
+
+
+
+
+
+ {sql || "No SQL available."}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/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/database/components/MigrationsPanel.tsx b/src/features/database/components/MigrationsPanel.tsx
index 08bdd13..2058b69 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}
@@ -184,15 +185,7 @@ function MigrationItem({ migration, onApply, onRollback, onDelete, onViewSQL }:
{migration.appliedAt && (
- Applied: {new Date(migration.appliedAt).toLocaleString('en-IN', {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- hour12: true,
- timeZone: 'Asia/Kolkata'
- })}
+ Applied: {formatTimestamp(migration.appliedAt)}
)}
diff --git a/src/features/git/components/GitStatusPanel.tsx b/src/features/git/components/GitStatusPanel.tsx
index 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
+
+
+
+ {syncing ? : }
+ Sync to Git
+
+
+ {pushing ? : }
+ Push
+
+
+
+ )}
{/* Tabs */}
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/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/home/components/MigrationStatusCard.tsx b/src/features/home/components/MigrationStatusCard.tsx
new file mode 100644
index 0000000..f73bdd6
--- /dev/null
+++ b/src/features/home/components/MigrationStatusCard.tsx
@@ -0,0 +1,110 @@
+import { useState } from "react";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { useImportAnalysis } from "@/features/project/hooks/useImportAnalysis";
+import { AlertTriangle, Database, RefreshCw, CheckCircle2 } from "lucide-react";
+import { SchemaDriftSheet } from "@/features/project/components/SchemaDriftSheet";
+import { Skeleton } from "@/components/ui/skeleton";
+import { cn } from "@/lib/utils";
+
+interface MigrationStatusCardProps {
+ projectId: string;
+ databaseId: string;
+ connectionName: string;
+}
+
+export function MigrationStatusCard({ projectId, databaseId, connectionName }: MigrationStatusCardProps) {
+ const { analysis, loading, refetch } = useImportAnalysis(projectId, databaseId);
+ const [driftSheetOpen, setDriftSheetOpen] = useState(false);
+
+ if (loading && !analysis) {
+ return ;
+ }
+
+ if (!analysis) return null;
+
+ const isDrifted = analysis.driftStatus === "drifted";
+ const isSynced = analysis.driftStatus === "synced";
+
+ return (
+ <>
+
+
+
+
+
+ Migration Status
+
+ {isSynced ? (
+
+
+ Synced
+
+ ) : isDrifted ? (
+
+
+ Drift Detected
+
+ ) : (
+
+ {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 && (
+
+ Drift detected — pending migrations have not been applied to the live database.
+
+ )}
+
+
+
+
+ {loading ? "Refreshing..." : "Refresh"}
+
+ {isDrifted && (
+ setDriftSheetOpen(true)} className="flex-1">
+ View Drift
+
+ )}
+
+
+
+
+
+ {/* Always render the sheet — it manages its own empty-state guard */}
+
+ >
+ );
+}
diff --git a/src/features/home/components/WelcomeView.tsx b/src/features/home/components/WelcomeView.tsx
index cb97e87..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/features/project/components/MigrationSyncDialog.tsx b/src/features/project/components/MigrationSyncDialog.tsx
new file mode 100644
index 0000000..0340e01
--- /dev/null
+++ b/src/features/project/components/MigrationSyncDialog.tsx
@@ -0,0 +1,290 @@
+import { useEffect, useState } from "react";
+import { ImportAnalysis } from "../types";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Badge } from "@/components/ui/badge";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { AlertTriangle, ShieldAlert, FileJson, Loader2, Database, ShieldX, Play } from "lucide-react";
+import { projectService } from "@/services/bridge/project";
+import { toast } from "sonner";
+import { cn } from "@/lib/utils";
+import SQLPreviewSheet from "@/components/ui/SQLPreviewSheet";
+
+interface MigrationSyncDialogProps {
+ projectId: string;
+ analysis: ImportAnalysis | null;
+ onClose: () => void;
+ onApplied: () => void;
+}
+
+export function MigrationSyncDialog({
+ projectId,
+ analysis,
+ onClose,
+ onApplied,
+}: MigrationSyncDialogProps) {
+ const [open, setOpen] = useState(false);
+ const [isApplying, setIsApplying] = useState(false);
+ const [previewSql, setPreviewSql] = useState(null);
+
+ useEffect(() => {
+ if (analysis) {
+ if (analysis.driftStatus === "synced") {
+ toast.success("Project is synced", {
+ description: "Schema matches exactly — nothing to apply.",
+ duration: 3000,
+ });
+ onClose();
+ return;
+ }
+ setOpen(true);
+ } else {
+ setOpen(false);
+ }
+ }, [analysis, onClose]);
+
+ const handleApplyMigrations = async () => {
+ try {
+ setIsApplying(true);
+ await projectService.applyMigrations(projectId);
+ toast.success("Migrations applied successfully");
+ onApplied();
+ setOpen(false);
+ } catch (error: any) {
+ toast.error("Failed to apply migrations", {
+ description: error.message,
+ });
+ } finally {
+ setIsApplying(false);
+ }
+ };
+
+ const handleApplySnapshot = async () => {
+ try {
+ setIsApplying(true);
+ await projectService.applySnapshot(projectId);
+ toast.success("Snapshot applied successfully");
+ onApplied();
+ setOpen(false);
+ } catch (error: any) {
+ toast.error("Failed to apply snapshot", {
+ description: error.message,
+ });
+ } finally {
+ setIsApplying(false);
+ }
+ };
+
+ const handlePreviewSQL = async () => {
+ try {
+ const sql = await projectService.generateSQL(projectId);
+ setPreviewSql(sql);
+ } catch (error: any) {
+ toast.error("Failed to generate SQL", {
+ description: error.message,
+ });
+ }
+ };
+
+ if (!analysis) return null;
+
+ // STATE 4: No migrations, no schema.json
+ if (!analysis.hasMigrations && !analysis.hasSchemaSnapshot) {
+ return (
+ { }}>
+ e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}>
+
+
+
+ Incomplete Project
+
+
+ We could not find any migration files or a schema snapshot in this repository.
+ RelWave cannot reconstruct the database from an empty state.
+
+
+
+ { setOpen(false); onClose(); }}>
+ Open Anyway
+
+
+
+
+ );
+ }
+
+ // STATE 2: Migrations exist, schema differs
+ if (analysis.hasMigrations && analysis.driftStatus !== "synced") {
+ const hasDestructive = analysis.pendingMigrations.some(m => m.isDestructive);
+ const tampered = analysis.lockFileStatus === "tampered";
+
+ return (
+ { if (!val) { setOpen(false); onClose(); } }}>
+
+
+
+
+ Pending Migrations
+
+
+ This project has new migrations that have not been applied to the live database.
+
+
+
+
+
+
+ {analysis.pendingMigrations.map((m) => (
+
+
+ {m.file}
+ {m.isDestructive && (
+ DESTRUCTIVE
+ )}
+
+ {m.isDestructive && m.destructiveOps.length > 0 && (
+
+
+ {m.destructiveOps.map((op, i) => {op} )}
+
+
+ )}
+
+ ))}
+
+
+
+ {hasDestructive && (
+
+
+ Warning: Destructive Operations
+
+ 1 or more migrations contain destructive operations that could lead to data loss.
+
+
+ )}
+
+ {!analysis.targetDatabaseEmpty && (
+
+
+ Target database is not empty
+
+ Applying migrations to a non-empty database may cause conflicts.
+
+
+ )}
+
+ {tampered && (
+
+
+ Lock File Mismatch
+
+ The following migration files may have been modified after being applied:
+
+ {analysis.tamperedFiles.map(f => {f} )}
+
+ Proceeding is not recommended.
+
+
+ )}
+
+
+
+ {isApplying ? (
+
+
+ Applying migrations...
+
+ ) : (
+ <>
+ { setOpen(false); onClose(); }}>
+ Skip for now
+
+
+
+ Apply Migrations
+
+ >
+ )}
+
+
+
+ );
+ }
+
+ // STATE 3: No migrations, schema.json exists
+ if (!analysis.hasMigrations && analysis.hasSchemaSnapshot && analysis.driftStatus !== "synced") {
+ return (
+ { if (!val) { setOpen(false); onClose(); } }}>
+
+
+
+
+ Apply Schema Snapshot
+
+
+ No migration history found, but a schema snapshot is available.
+ We can generate a baseline migration to reconstruct the schema.
+
+
+
+
+ {!analysis.targetDatabaseEmpty && (
+
+
+ Target database is not empty
+
+ Applying baseline to a non-empty database may cause conflicts.
+
+
+ )}
+
+
+
+ {isApplying ? (
+
+
+ Applying snapshot...
+
+ ) : (
+ <>
+
+ { setOpen(false); onClose(); }}>
+ Skip
+
+
+
+ Preview SQL
+
+
+ Generate & Apply
+
+ >
+ )}
+
+
+
+ { if (!val) setPreviewSql(null); }}
+ sql={previewSql || ""}
+ title="Baseline SQL Preview"
+ />
+
+ );
+ }
+
+ return null;
+}
diff --git a/src/features/project/components/SchemaDriftSheet.tsx b/src/features/project/components/SchemaDriftSheet.tsx
new file mode 100644
index 0000000..d591aa3
--- /dev/null
+++ b/src/features/project/components/SchemaDriftSheet.tsx
@@ -0,0 +1,162 @@
+import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
+import { SchemaDiff } from "../types";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { ChevronDown, RefreshCw } from "lucide-react";
+import { useState } from "react";
+import { projectService } from "@/services/bridge/project";
+import { toast } from "sonner";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { cn } from "@/lib/utils";
+
+interface SchemaDriftSheetProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ projectId: string;
+ connectionName: string;
+ capturedDate?: string;
+ driftDetails?: SchemaDiff;
+ onRefresh: () => void;
+}
+
+export function SchemaDriftSheet({
+ open,
+ onOpenChange,
+ projectId,
+ connectionName,
+ capturedDate,
+ driftDetails,
+ onRefresh,
+}: SchemaDriftSheetProps) {
+ const [isRefreshing, setIsRefreshing] = useState(false);
+
+ const handleRefresh = async () => {
+ try {
+ setIsRefreshing(true);
+ await projectService.refreshSchemaCache(projectId);
+ toast.success("Schema cache refreshed successfully");
+ onRefresh();
+ onOpenChange(false);
+ } catch (error: any) {
+ toast.error("Failed to refresh cache", { description: error.message });
+ } finally {
+ setIsRefreshing(false);
+ }
+ };
+
+ const hasAdded = (driftDetails?.tablesAdded?.length ?? 0) > 0;
+ const hasRemoved = (driftDetails?.tablesRemoved?.length ?? 0) > 0;
+ const hasModified = (driftDetails?.tablesModified?.length ?? 0) > 0;
+ const hasDriftData = hasAdded || hasRemoved || hasModified;
+
+ return (
+
+
+
+ Schema Drift — {connectionName}
+
+ Live database differs from schema.json
+ {capturedDate ? ` captured ${new Date(capturedDate).toLocaleString()}` : ""}.
+
+
+
+ {!hasDriftData ? (
+
+
🔍
+
No detailed drift data available
+
+ Structural schema diffing is not yet implemented. Drift was detected from pending
+ migrations, not a live schema comparison. This is tracked for a future release.
+
+
+ ) : (
+
+
+ {hasAdded && (
+
+
+
+ Tables Added
+
+ {driftDetails!.tablesAdded.length}
+
+
+
+
+
+
+ {driftDetails!.tablesAdded.map((t) => {t} )}
+
+
+
+ )}
+
+ {hasRemoved && (
+
+
+
+ Tables Removed
+
+ {driftDetails!.tablesRemoved.length}
+
+
+
+
+
+
+ {driftDetails!.tablesRemoved.map((t) => {t} )}
+
+
+
+ )}
+
+ {hasModified && (
+
+
+
+ Tables Modified
+
+ {driftDetails!.tablesModified.length}
+
+
+
+
+
+
+ {driftDetails!.tablesModified.map((m) => (
+
+
{m.tableName}
+ {m.columnsAdded.length > 0 && (
+
+ {m.columnsAdded.join(", ")}
+ )}
+ {m.columnsRemoved.length > 0 && (
+
- {m.columnsRemoved.join(", ")}
+ )}
+ {m.columnsChanged.length > 0 && (
+
~ {m.columnsChanged.join(", ")}
+ )}
+ {m.constraintsChanged.length > 0 && (
+
~ {m.constraintsChanged.join(", ")}
+ )}
+
+ ))}
+
+
+
+ )}
+
+
+ )}
+
+
+ onOpenChange(false)}>Close
+
+
+ Refresh Cache
+
+
+
+
+ );
+}
diff --git a/src/features/project/hooks/useImportAnalysis.ts b/src/features/project/hooks/useImportAnalysis.ts
new file mode 100644
index 0000000..c423770
--- /dev/null
+++ b/src/features/project/hooks/useImportAnalysis.ts
@@ -0,0 +1,37 @@
+import { useState, useEffect, useCallback } from "react";
+import { projectService } from "@/services/bridge/project";
+import { ImportAnalysis } from "../types";
+
+export function useImportAnalysis(projectId?: string, targetDatabaseId?: string) {
+ const [analysis, setAnalysis] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const fetchAnalysis = useCallback(async () => {
+ if (!projectId) return;
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await projectService.analyzeImport(projectId);
+ setAnalysis(data);
+ } catch (err: any) {
+ console.error("Failed to fetch import analysis:", err);
+ setError(err instanceof Error ? err : new Error(err?.message || "Unknown error"));
+ } finally {
+ setLoading(false);
+ }
+ }, [projectId]);
+
+ useEffect(() => {
+ if (projectId && targetDatabaseId) {
+ fetchAnalysis();
+ }
+ }, [projectId, targetDatabaseId, fetchAnalysis]);
+
+ return {
+ analysis,
+ loading,
+ error,
+ refetch: fetchAnalysis
+ };
+}
diff --git a/src/features/project/hooks/useProjectSync.ts b/src/features/project/hooks/useProjectSync.ts
index 6a629b2..8dbcc2c 100644
--- a/src/features/project/hooks/useProjectSync.ts
+++ b/src/features/project/hooks/useProjectSync.ts
@@ -1,8 +1,9 @@
-import { useEffect, useRef, useCallback } from "react";
+import { useEffect, useRef, useCallback, useState } from "react";
import { useProjectByDatabaseId } from "@/features/project/hooks/useProjectQueries";
import { schemaGroupsToSnapshots } from "@/lib/schemaConverters";
import type { DatabaseSchemaDetails } from "@/features/database/types";
import type { ERNode } from "@/features/project/types";
+import type { ImportAnalysis } from "@/features/project/types";
import { projectService } from "@/services/bridge/project";
// ==========================================
@@ -23,6 +24,12 @@ interface UseProjectSyncReturn {
isLoading: boolean;
/** Save ER diagram node positions (debounced externally by caller) */
saveERDiagram: (nodes: ERNode[], zoom?: number, panX?: number, panY?: number) => void;
+ /** Import analysis result — null while loading or if no project */
+ importAnalysis: ImportAnalysis | null;
+ /** Whether the import analysis is still loading */
+ importAnalysisLoading: boolean;
+ /** Re-fetch import analysis (e.g. after applying migrations) */
+ refetchImportAnalysis: () => void;
}
export function useProjectSync(
@@ -35,12 +42,54 @@ export function useProjectSync(
// Track what we last synced to avoid redundant writes
const lastSyncedSchemaRef = useRef(null);
+ // -----------------------------------------
+ // Import analysis — check if project has pending migrations
+ // -----------------------------------------
+ const [importAnalysis, setImportAnalysis] = useState(null);
+ const [importAnalysisLoading, setImportAnalysisLoading] = useState(false);
+ const analysisCheckedRef = useRef(null);
+
+ const fetchImportAnalysis = useCallback(async () => {
+ if (!projectId) return;
+ setImportAnalysisLoading(true);
+ try {
+ const data = await projectService.analyzeImport(projectId);
+ setImportAnalysis(data);
+ } catch (err: any) {
+ console.warn("[ProjectSync] Import analysis failed:", err.message);
+ // On failure, allow schema sync to proceed (fail-open)
+ setImportAnalysis(null);
+ } finally {
+ setImportAnalysisLoading(false);
+ }
+ }, [projectId]);
+
+ useEffect(() => {
+ if (projectId && projectId !== analysisCheckedRef.current) {
+ analysisCheckedRef.current = projectId;
+ fetchImportAnalysis();
+ }
+ }, [projectId, fetchImportAnalysis]);
+
// -----------------------------------------
// Auto-sync schema when fresh data arrives
// -----------------------------------------
useEffect(() => {
if (!projectId || !schemaData?.schemas?.length) return;
+ // Don't overwrite the imported schema if there are pending migrations
+ // or a schema snapshot waiting to be applied to an empty database.
+ // Only auto-sync when analysis confirms the project is "synced"
+ // (i.e., the live DB matches the project state).
+ if (importAnalysisLoading) return; // wait until analysis finishes
+ if (importAnalysis && importAnalysis.driftStatus !== "synced") {
+ console.debug(
+ "[ProjectSync] Skipping schema auto-save — project has pending drift:",
+ importAnalysis.driftStatus
+ );
+ return;
+ }
+
// Build a lightweight fingerprint to avoid re-saving identical data.
// We use schema/table count as a quick check (cheap to compute).
const fingerprint = schemaData.schemas
@@ -61,7 +110,7 @@ export function useProjectSync(
.catch((err) => {
console.warn("[ProjectSync] Schema sync failed:", err.message);
});
- }, [projectId, schemaData]);
+ }, [projectId, schemaData, importAnalysis, importAnalysisLoading]);
// -----------------------------------------
// ER Diagram save helper
@@ -81,5 +130,5 @@ export function useProjectSync(
[projectId]
);
- return { projectId, isLoading, saveERDiagram };
+ return { projectId, isLoading, saveERDiagram, importAnalysis, importAnalysisLoading, refetchImportAnalysis: fetchImportAnalysis };
}
diff --git a/src/features/project/types.ts b/src/features/project/types.ts
index 7e1cf8e..02efbb4 100644
--- a/src/features/project/types.ts
+++ b/src/features/project/types.ts
@@ -144,3 +144,50 @@ export interface ImportProjectParams {
sourcePath: string;
databaseId: string;
}
+
+// ==========================================
+// Sync and Migration Types
+// ==========================================
+
+export interface SchemaDiff {
+ tablesAdded: string[];
+ tablesRemoved: string[];
+ tablesModified: Array<{
+ tableName: string;
+ columnsAdded: string[];
+ columnsRemoved: string[];
+ columnsChanged: string[];
+ constraintsChanged: string[];
+ }>;
+}
+
+export interface ImportAnalysis {
+ hasMigrations: boolean;
+ migrationCount: number;
+ hasSchemaSnapshot: boolean;
+ lockFileStatus: "valid" | "tampered" | "missing";
+ tamperedFiles: string[];
+
+ targetDatabaseEmpty: boolean;
+ targetTableCount: number;
+
+ driftStatus: "synced" | "drifted" | "unknown";
+ driftDetails?: SchemaDiff;
+
+ pendingMigrations: Array<{
+ file: string;
+ version: string;
+ isDestructive: boolean;
+ destructiveOps: string[];
+ }>;
+
+ availableModes: Array<"run_migrations" | "apply_snapshot" | "skip">;
+ recommendedMode: "run_migrations" | "apply_snapshot" | "skip";
+}
+
+export interface MigrationLockVerifyResult {
+ valid: boolean;
+ tamperedFiles: string[];
+ missingFiles: string[];
+ unknownFiles: string[];
+}
diff --git a/src/features/schema-explorer/components/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/lib/utils.ts b/src/lib/utils.ts
index bd0c391..19215ca 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -4,3 +4,40 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+
+/**
+ * Format an ISO timestamp string to a readable local date/time.
+ * e.g. "2026-06-12T12:46:20.671Z" → "12 Jun 2026, 12:46 pm"
+ */
+export function formatTimestamp(dateString?: string | null): string {
+ if (!dateString) return "—";
+ const date = new Date(dateString);
+ if (isNaN(date.getTime())) return dateString;
+ return date.toLocaleString(undefined, {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: true,
+ });
+}
+
+/**
+ * Show relative time for recent events, fall back to formatTimestamp for older ones.
+ * e.g. "5m ago", "2h ago", "3d ago", or "12 Jun 2026, 12:46 pm"
+ */
+export function formatRelativeTime(dateString?: string | null): string {
+ if (!dateString) return "Never";
+ const date = new Date(dateString);
+ if (isNaN(date.getTime())) return dateString;
+ const diffMs = Date.now() - date.getTime();
+ const diffMins = Math.floor(diffMs / 60000);
+ const diffHours = Math.floor(diffMs / 3600000);
+ const diffDays = Math.floor(diffMs / 86400000);
+ if (diffMins < 1) return "Just now";
+ if (diffMins < 60) return `${diffMins}m ago`;
+ if (diffHours < 24) return `${diffHours}h ago`;
+ if (diffDays < 7) return `${diffDays}d ago`;
+ return formatTimestamp(dateString);
+}
diff --git a/src/pages/DatabaseDetails.tsx b/src/pages/DatabaseDetails.tsx
index 60529ab..87a7ba8 100644
--- a/src/pages/DatabaseDetails.tsx
+++ b/src/pages/DatabaseDetails.tsx
@@ -29,6 +29,7 @@ import GitStatusBar from "@/features/git/components/GitStatusBar";
import { MonitoringPanel } from "@/features/monitoring/components/MonitoringPanel";
import { ShortcutsHelp } from "@/components/shared/ShortcutsHelp";
import { ShortcutsTrigger } from "@/components/shared/ShortcutsTrigger";
+import { MigrationSyncDialog } from "@/features/project/components/MigrationSyncDialog";
import { useState } from "react";
const DatabaseDetail = () => {
@@ -94,9 +95,10 @@ const DatabaseDetail = () => {
const baselined = migrationsResponse?.baselined || false;
// Project sync
- const { data: schemaData } = useFullSchema(dbId);
- const { projectId } = useProjectSync(dbId, schemaData ?? undefined);
+ const { data: schemaData, refetch: refetchSchema } = useFullSchema(dbId);
+ const { projectId, importAnalysis, importAnalysisLoading, refetchImportAnalysis } = useProjectSync(dbId, schemaData ?? undefined);
const { data: projectDir } = useProjectDir(projectId);
+ const [migrationSyncDismissed, setMigrationSyncDismissed] = useState(false);
// ---- Guards ----
if (bridgeLoading || bridgeReady === undefined) return
;
@@ -110,7 +112,8 @@ const DatabaseDetail = () => {
case "schema-explorer": return
;
case "er-diagram": return
;
case "monitoring": return
;
- case "git-status": return
;
+ case "git-status": return
;
+ case "migrations": return
;
default:
return (
{
sidebarOpen={sidebarOpen}
onToggleSidebar={toggleSidebar}
onRefresh={fetchTables}
- onMigrationsOpen={() => setMigrationsOpen(true)}
+ onMigrationsOpen={() => setActivePanel("migrations")}
onExport={exportAllTables}
isExporting={isExporting}
onChart={() => setChartOpen(true)}
@@ -183,10 +186,20 @@ const DatabaseDetail = () => {
- {/* Migrations */}
- setMigrationsOpen(false)} title="Migrations" disableScroll>
-
-
+ {/* Migration Sync Dialog — prompts user when imported project has pending migrations */}
+ {projectId && !migrationSyncDismissed && !importAnalysisLoading && (
+ setMigrationSyncDismissed(true)}
+ onApplied={() => {
+ setMigrationSyncDismissed(true);
+ refetchImportAnalysis();
+ fetchTables();
+ refetchSchema();
+ }}
+ />
+ )}
{/* Chart */}
setChartOpen(false)} title={`Chart: ${selectedTable?.name || "Table"}`} width="60%">
diff --git a/src/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