diff --git a/Dockerfile b/Dockerfile index 6564456..cbd52c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,11 @@ COPY . . ARG GIT_COMMIT=unknown RUN GIT_COMMIT=${GIT_COMMIT} pnpm build +ARG TARGETARCH=amd64 +RUN unzip bin/pgschema-linux-${TARGETARCH}.zip -d /usr/local/bin \ + && mv /usr/local/bin/pgschema-linux-${TARGETARCH} /usr/local/bin/pgschema \ + && chmod +x /usr/local/bin/pgschema + # Runtime dependencies # Generated from esbuild externals + package.json versions (single source of truth). # Only rebuilds when package.json or build-server.mjs externals change. @@ -43,13 +48,16 @@ RUN node scripts/gen-runtime-package.mjs > runtime-package.json \ # Layers ordered least → most frequently changing for cache efficiency FROM alpine:3.21 -RUN apk add --no-cache libstdc++ +RUN apk add --no-cache libstdc++ git COPY --from=node:22-alpine /usr/local/bin/node /usr/local/bin/node +RUN addgroup -S pgconsole && adduser -S pgconsole -G pgconsole + WORKDIR /app -# 1. Entrypoint — rarely changes +# 1. Binaries & entrypoint — rarely change +COPY --from=builder /usr/local/bin/pgschema /usr/local/bin/pgschema COPY docker-entrypoint.sh /app/ # 2. Runtime node_modules — changes only when externals or dep versions change @@ -74,4 +82,6 @@ ENV NODE_ENV=production ENV PORT=9876 EXPOSE 9876 +USER pgconsole + ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/bin/pgschema-linux-amd64.zip b/bin/pgschema-linux-amd64.zip new file mode 100644 index 0000000..2f584fe Binary files /dev/null and b/bin/pgschema-linux-amd64.zip differ diff --git a/bin/pgschema-linux-arm64.zip b/bin/pgschema-linux-arm64.zip new file mode 100644 index 0000000..29a93c3 Binary files /dev/null and b/bin/pgschema-linux-arm64.zip differ diff --git a/pgconsole.example.toml b/pgconsole.example.toml index f0647f3..cf2c44a 100644 --- a/pgconsole.example.toml +++ b/pgconsole.example.toml @@ -96,6 +96,12 @@ username = "app_user" password = "staging_password" ssl_mode = "require" labels = ["staging"] +# Schema migration source (optional) — enables migration features +# [connections.schema_source] +# repo = "https://github.com/myorg/db-schema.git" +# branch = "main" +# path = "schema/main.sql" +# schema = "public" # Example with full SSL configuration and timeouts # [[connections]] diff --git a/proto/migration.proto b/proto/migration.proto new file mode 100644 index 0000000..e72438f --- /dev/null +++ b/proto/migration.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; + +package migration.v1; + +service MigrationService { + rpc PlanMigration(PlanMigrationRequest) returns (PlanMigrationResponse); + rpc ApplyMigration(ApplyMigrationRequest) returns (stream ApplyMigrationResponse); + rpc GetSchemaSourceStatus(GetSchemaSourceStatusRequest) returns (GetSchemaSourceStatusResponse); +} + +message PlanMigrationRequest { + string connection_id = 1; +} + +message SchemaDiff { + string sql = 1; + string type = 2; + string operation = 3; + string path = 4; + bool can_run_in_transaction = 5; +} + +message PlanMigrationResponse { + string plan_id = 1; + string branch = 2; + string commit_hash = 3; + string source_fingerprint = 4; + repeated SchemaDiff diffs = 5; + bool can_run_in_transaction = 6; + string summary = 7; +} + +message ApplyMigrationRequest { + string connection_id = 1; + string plan_id = 2; +} + +message ApplyMigrationResponse { + int32 step = 1; + int32 total_steps = 2; + string sql = 3; + string status = 4; + string error = 5; +} + +message GetSchemaSourceStatusRequest { + string connection_id = 1; +} + +message GetSchemaSourceStatusResponse { + bool configured = 1; + string repo = 2; + string branch = 3; + string path = 4; + string schema = 5; +} diff --git a/server/connect.ts b/server/connect.ts index c017c13..2c625a8 100644 --- a/server/connect.ts +++ b/server/connect.ts @@ -1,14 +1,26 @@ import { expressConnectMiddleware } from "@connectrpc/connect-express"; +import type { Interceptor } from "@connectrpc/connect"; import type { Request } from "express"; import { ConnectionService } from "../src/gen/connection_connect"; import { QueryService } from "../src/gen/query_connect"; import { AIService } from "../src/gen/ai_connect"; +import { MigrationService } from "../src/gen/migration_connect"; import { connectionServiceHandlers } from "./services/connection-service"; import { queryServiceHandlers } from "./services/query-service"; import { aiServiceHandlers } from "./services/ai-service"; +import { migrationServiceHandlers } from "./services/migration-service"; import { getCurrentUser, type User } from "./lib/auth"; import { isAuthEnabled } from "./lib/config"; +const loggingInterceptor: Interceptor = (next) => async (req) => { + try { + return await next(req) + } catch (err) { + console.error(`[RPC] ${req.service.typeName}/${req.method.name}:`, err) + throw err + } +} + // Helper to get user from ConnectRPC context // Note: contextValues may be a Promise if contextValues factory is async export async function getUserFromContext(contextValues: Map | Promise>): Promise { @@ -25,9 +37,10 @@ const GUEST_USER: User = { email: 'guest', name: 'Guest' } */ export const connectRouter = expressConnectMiddleware({ routes: (router) => { - router.service(ConnectionService, connectionServiceHandlers); - router.service(QueryService, queryServiceHandlers); - router.service(AIService, aiServiceHandlers); + router.service(ConnectionService, connectionServiceHandlers, { interceptors: [loggingInterceptor] }); + router.service(QueryService, queryServiceHandlers, { interceptors: [loggingInterceptor] }); + router.service(AIService, aiServiceHandlers, { interceptors: [loggingInterceptor] }); + router.service(MigrationService, migrationServiceHandlers, { interceptors: [loggingInterceptor] }); }, // Set max message size to ~4GB for large query results readMaxBytes: 0xffffffff, diff --git a/server/index.ts b/server/index.ts index 6bc0626..eb7b6f8 100644 --- a/server/index.ts +++ b/server/index.ts @@ -68,7 +68,8 @@ app.use((req, res, next) => { req.path.startsWith('/api/') || req.path.startsWith('/connection.v1.') || req.path.startsWith('/query.v1.') || - req.path.startsWith('/ai.v1.')) { + req.path.startsWith('/ai.v1.') || + req.path.startsWith('/migration.v1.')) { return next() } res.sendFile(path.join(clientDir, 'index.html')) diff --git a/server/lib/config.ts b/server/lib/config.ts index 18f94a8..6ca9c42 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -10,6 +10,13 @@ export interface LabelConfig { color: string } +export interface SchemaSourceConfig { + repo: string + branch?: string + path: string + schema: string +} + export interface ConnectionConfig { id: string name: string @@ -26,6 +33,7 @@ export interface ConnectionConfig { lock_timeout?: string statement_timeout?: string lazy?: boolean + schema_source?: SchemaSourceConfig } export interface UserConfig { @@ -406,6 +414,24 @@ export async function loadConfigFromString(content: string): Promise { } } + // Parse schema_source if provided + let schemaSource: SchemaSourceConfig | undefined = undefined + const rawSchemaSource = c.schema_source as Record | undefined + if (rawSchemaSource) { + if (!rawSchemaSource.repo || typeof rawSchemaSource.repo !== 'string') { + throw new Error(`Connection ${c.id} schema_source.repo is required and must be a string`) + } + if (!rawSchemaSource.path || typeof rawSchemaSource.path !== 'string') { + throw new Error(`Connection ${c.id} schema_source.path is required and must be a string`) + } + schemaSource = { + repo: rawSchemaSource.repo, + branch: typeof rawSchemaSource.branch === 'string' ? rawSchemaSource.branch : undefined, + path: rawSchemaSource.path, + schema: typeof rawSchemaSource.schema === 'string' ? rawSchemaSource.schema : 'public', + } + } + connections.push({ id: c.id, name: c.name, @@ -422,6 +448,7 @@ export async function loadConfigFromString(content: string): Promise { lock_timeout: typeof c.lock_timeout === 'string' ? c.lock_timeout : undefined, statement_timeout: typeof c.statement_timeout === 'string' ? c.statement_timeout : undefined, lazy: c.lazy === true, + schema_source: schemaSource, }) } diff --git a/server/lib/git.ts b/server/lib/git.ts new file mode 100644 index 0000000..6120765 --- /dev/null +++ b/server/lib/git.ts @@ -0,0 +1,70 @@ +import { execFile } from 'child_process' +import { access, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +function exec(cmd: string, args: string[], cwd?: string): Promise { + return new Promise((resolve, reject) => { + execFile(cmd, args, { cwd, timeout: 60_000 }, (error, stdout, stderr) => { + if (error) { + reject(new Error(`git ${args[0]} failed: ${stderr || error.message}`)) + } else { + resolve(stdout.trim()) + } + }) + }) +} + +// Cache the repo URL per directory so we can detect config changes +const repoDirUrls = new Map() +// Per-connection lock to prevent concurrent clone/fetch races +const syncLocks = new Map>() + +export function getRepoDir(connectionId: string): string { + return join(tmpdir(), 'pgconsole-schema', connectionId) +} + +export async function syncRepo(connectionId: string, repo: string, branch?: string): Promise<{ commitHash: string }> { + // Serialize concurrent sync requests for the same connection + const existing = syncLocks.get(connectionId) + if (existing) { + return existing + } + + const promise = doSyncRepo(connectionId, repo, branch).finally(() => { + syncLocks.delete(connectionId) + }) + syncLocks.set(connectionId, promise) + return promise +} + +async function doSyncRepo(connectionId: string, repo: string, branch?: string): Promise<{ commitHash: string }> { + const repoDir = getRepoDir(connectionId) + + const exists = await access(join(repoDir, '.git')).then(() => true).catch(() => false) + + // If the repo URL changed, wipe the old checkout + if (exists) { + const cachedUrl = repoDirUrls.get(repoDir) + if (cachedUrl && cachedUrl !== repo) { + await rm(repoDir, { recursive: true, force: true }) + } + } + + const stillExists = await access(join(repoDir, '.git')).then(() => true).catch(() => false) + + if (stillExists) { + await exec('git', ['fetch', 'origin', ...(branch ? [branch] : [])], repoDir) + await exec('git', ['reset', '--hard', branch ? `origin/${branch}` : 'FETCH_HEAD'], repoDir) + } else { + const cloneArgs = ['clone', '--depth', '1'] + if (branch) cloneArgs.push('--branch', branch) + cloneArgs.push(repo, repoDir) + await exec('git', cloneArgs) + } + + repoDirUrls.set(repoDir, repo) + + const commitHash = await exec('git', ['rev-parse', 'HEAD'], repoDir) + return { commitHash } +} diff --git a/server/lib/pgschema.ts b/server/lib/pgschema.ts new file mode 100644 index 0000000..af69204 --- /dev/null +++ b/server/lib/pgschema.ts @@ -0,0 +1,146 @@ +import { execFile } from 'child_process' +import type { ConnectionConfig } from './config' + +export interface PlanDiff { + sql: string + type: string + operation: string + path: string + canRunInTransaction: boolean +} + +export interface ParsedPlan { + sourceFingerprint: string + diffs: PlanDiff[] + canRunInTransaction: boolean + summary: string +} + +export interface PgSchemaPlanJson { + schemas?: Record + }> + }> +} + +export function parsePlanJson(json: PgSchemaPlanJson, schema: string): ParsedPlan { + const schemaData = json.schemas?.[schema] + const sourceFingerprint = schemaData?.source_fingerprint?.hash ?? '' + + const diffs: PlanDiff[] = [] + let canRunInTransaction = true + for (const group of schemaData?.groups ?? []) { + const groupTxn = group.can_run_in_transaction !== false + if (!groupTxn) canRunInTransaction = false + for (const step of group.steps) { + diffs.push({ + sql: step.sql, + type: step.type, + operation: step.operation, + path: step.path, + canRunInTransaction: groupTxn, + }) + } + } + + const counts = new Map() + for (const d of diffs) { + counts.set(d.operation, (counts.get(d.operation) || 0) + 1) + } + + let summary: string + if (diffs.length === 0) { + summary = 'No changes' + } else { + const parts: string[] = [] + for (const op of ['create', 'alter', 'drop']) { + const count = counts.get(op) + if (count) parts.push(`${count} to ${op}`) + } + summary = `${diffs.length} changes: ${parts.join(', ')}` + } + + return { sourceFingerprint, diffs, canRunInTransaction, summary } +} + +function connectionArgs(conn: ConnectionConfig): string[] { + return [ + '--host', conn.host, + '--port', String(conn.port), + '--db', conn.database, + '--user', conn.username, + '--sslmode', conn.ssl_mode || 'prefer', + ...(conn.ssl_ca ? ['--ssl-ca', conn.ssl_ca] : []), + ...(conn.ssl_cert ? ['--ssl-cert', conn.ssl_cert] : []), + ...(conn.ssl_key ? ['--ssl-key', conn.ssl_key] : []), + ...(conn.statement_timeout ? ['--statement-timeout', conn.statement_timeout] : []), + ] +} + +function connectionEnv(conn: ConnectionConfig): Record { + const env: Record = {} + if (conn.password) { + env.PGPASSWORD = conn.password + } + return env +} + +function execPgSchema(args: string[], timeoutMs: number, extraEnv?: Record): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const env = extraEnv ? { ...process.env, ...extraEnv } : undefined + execFile('pgschema', args, { timeout: timeoutMs, env }, (error, stdout, stderr) => { + if (error) { + reject(new Error(stderr || error.message)) + } else { + resolve({ stdout, stderr }) + } + }) + }) +} + +export async function runPgSchemaPlan( + conn: ConnectionConfig, + schemaFilePath: string, + outputJsonPath: string, + pgSchema: string, +): Promise { + try { + await execPgSchema([ + 'plan', + ...connectionArgs(conn), + '--schema', pgSchema, + '--file', schemaFilePath, + '--output-json', outputJsonPath, + '--no-color', + ], 120_000, connectionEnv(conn)) + } catch (err) { + throw new Error(`pgschema plan failed: ${(err as Error).message}`) + } +} + +export async function runPgSchemaApply( + conn: ConnectionConfig, + planJsonPath: string, +): Promise { + try { + const { stdout } = await execPgSchema([ + 'apply', + ...connectionArgs(conn), + '--plan', planJsonPath, + '--auto-approve', + '--no-color', + ...(conn.lock_timeout ? ['--lock-timeout', conn.lock_timeout] : []), + ], 300_000, connectionEnv(conn)) + return stdout + } catch (err) { + throw new Error(`pgschema apply failed: ${(err as Error).message}`) + } +} diff --git a/server/lib/plan-store.ts b/server/lib/plan-store.ts new file mode 100644 index 0000000..d241922 --- /dev/null +++ b/server/lib/plan-store.ts @@ -0,0 +1,48 @@ +import { randomUUID } from 'crypto' +import type { PgSchemaPlanJson } from './pgschema' + +const PLAN_TTL_MS = 30 * 60 * 1000 + +export interface StoredPlan { + connectionId: string + planJsonPath: string + planData: PgSchemaPlanJson + schema: string + createdAt: number +} + +const plans = new Map() + +function evictExpired(): void { + const now = Date.now() + for (const [id, plan] of plans) { + if (now - plan.createdAt > PLAN_TTL_MS) plans.delete(id) + } +} + +export function storePlan(opts: { connectionId: string; planJsonPath: string; planData: PgSchemaPlanJson; schema: string }): string { + evictExpired() + const id = randomUUID() + plans.set(id, { + connectionId: opts.connectionId, + planJsonPath: opts.planJsonPath, + planData: opts.planData, + schema: opts.schema, + createdAt: Date.now(), + }) + return id +} + +export function getPlan(planId: string): StoredPlan | undefined { + const plan = plans.get(planId) + if (!plan) return undefined + if (Date.now() - plan.createdAt > PLAN_TTL_MS) { + plans.delete(planId) + return undefined + } + return plan +} + +export function removePlan(planId: string): void { + plans.delete(planId) +} diff --git a/server/services/migration-service.ts b/server/services/migration-service.ts new file mode 100644 index 0000000..899b2fa --- /dev/null +++ b/server/services/migration-service.ts @@ -0,0 +1,189 @@ +import { ConnectError, Code } from '@connectrpc/connect' +import type { ServiceImpl } from '@connectrpc/connect' +import { MigrationService } from '../../src/gen/migration_connect' +import { getConnectionById } from '../lib/config' +import { getUserFromContext } from '../connect' +import { requirePermission } from '../lib/iam' +import { syncRepo, getRepoDir } from '../lib/git' +import { runPgSchemaPlan, runPgSchemaApply, parsePlanJson, type PgSchemaPlanJson } from '../lib/pgschema' +import { storePlan, getPlan, removePlan } from '../lib/plan-store' +import { readFile } from 'fs/promises' +import { join, resolve } from 'path' +import { tmpdir } from 'os' +import { randomUUID } from 'crypto' + +function validateSchemaPath(repoDir: string, schemaPath: string): string { + const resolved = resolve(repoDir, schemaPath) + if (!resolved.startsWith(resolve(repoDir) + '/')) { + throw new ConnectError('schema_source.path escapes the repository directory', Code.InvalidArgument) + } + return resolved +} + +export const migrationServiceHandlers: ServiceImpl = { + async planMigration(req, context) { + if (!req.connectionId) { + throw new ConnectError('connection_id is required', Code.InvalidArgument) + } + + const conn = getConnectionById(req.connectionId) + if (!conn) { + throw new ConnectError('Connection not found', Code.NotFound) + } + + if (!conn.schema_source) { + throw new ConnectError('Connection does not have a schema_source configured', Code.FailedPrecondition) + } + + const user = await getUserFromContext(context.values) + requirePermission(user, req.connectionId, 'read', 'plan migration') + + const { repo, branch, path: schemaPath, schema: pgSchema } = conn.schema_source + + let commitHash: string + try { + const result = await syncRepo(req.connectionId, repo, branch) + commitHash = result.commitHash + } catch (err) { + throw new ConnectError( + `Failed to sync git repo: ${err instanceof Error ? err.message : String(err)}`, + Code.Internal, + ) + } + + const repoDir = getRepoDir(req.connectionId) + const schemaFilePath = validateSchemaPath(repoDir, schemaPath) + const outputJsonPath = join(tmpdir(), `pgconsole-plan-${randomUUID()}.json`) + + try { + await runPgSchemaPlan(conn, schemaFilePath, outputJsonPath, pgSchema) + } catch (err) { + throw new ConnectError( + `pgschema plan failed: ${err instanceof Error ? err.message : String(err)}`, + Code.Internal, + ) + } + + let planJson: PgSchemaPlanJson + try { + const raw = await readFile(outputJsonPath, 'utf-8') + planJson = JSON.parse(raw) as PgSchemaPlanJson + } catch (err) { + throw new ConnectError( + `Failed to read plan output: ${err instanceof Error ? err.message : String(err)}`, + Code.Internal, + ) + } + + const parsed = parsePlanJson(planJson, pgSchema) + + const planId = storePlan({ + connectionId: req.connectionId, + planJsonPath: outputJsonPath, + planData: planJson, + schema: pgSchema, + }) + + return { + planId, + branch: branch || 'default', + commitHash, + sourceFingerprint: parsed.sourceFingerprint, + diffs: parsed.diffs.map(d => ({ + sql: d.sql, + type: d.type, + operation: d.operation, + path: d.path, + canRunInTransaction: d.canRunInTransaction, + })), + canRunInTransaction: parsed.canRunInTransaction, + summary: parsed.summary, + } + }, + + async *applyMigration(req, context) { + if (!req.connectionId) { + throw new ConnectError('connection_id is required', Code.InvalidArgument) + } + if (!req.planId) { + throw new ConnectError('plan_id is required', Code.InvalidArgument) + } + + const conn = getConnectionById(req.connectionId) + if (!conn) { + throw new ConnectError('Connection not found', Code.NotFound) + } + + const user = await getUserFromContext(context.values) + requirePermission(user, req.connectionId, 'ddl', 'apply migration') + + const plan = getPlan(req.planId) + if (!plan) { + throw new ConnectError('Plan not found or expired. Please re-run plan.', Code.NotFound) + } + + if (plan.connectionId !== req.connectionId) { + throw new ConnectError('Plan does not match connection', Code.InvalidArgument) + } + + const parsed = parsePlanJson(plan.planData as Parameters[0], plan.schema) + const totalSteps = parsed.diffs.length + + yield { + step: 0, + totalSteps, + sql: '', + status: 'running', + error: '', + } + + try { + await runPgSchemaApply(conn, plan.planJsonPath) + } catch (err) { + yield { + step: totalSteps, + totalSteps, + sql: '', + status: 'failed', + error: err instanceof Error ? err.message : String(err), + } + return + } + + yield { + step: totalSteps, + totalSteps, + sql: '', + status: 'completed', + error: '', + } + + removePlan(req.planId) + }, + + async getSchemaSourceStatus(req, context) { + if (!req.connectionId) { + throw new ConnectError('connection_id is required', Code.InvalidArgument) + } + + const conn = getConnectionById(req.connectionId) + if (!conn) { + throw new ConnectError('Connection not found', Code.NotFound) + } + + const user = await getUserFromContext(context.values) + requirePermission(user, req.connectionId, 'read', 'check schema source status') + + if (!conn.schema_source) { + return { configured: false, repo: '', branch: '', path: '', schema: '' } + } + + return { + configured: true, + repo: conn.schema_source.repo, + branch: conn.schema_source.branch || '', + path: conn.schema_source.path, + schema: conn.schema_source.schema, + } + }, +} diff --git a/src/components/sql-editor/ContextPanel.tsx b/src/components/sql-editor/ContextPanel.tsx index 510828c..e0b0a73 100644 --- a/src/components/sql-editor/ContextPanel.tsx +++ b/src/components/sql-editor/ContextPanel.tsx @@ -5,7 +5,7 @@ import { ScrollArea } from '../ui/scroll-area' import { Badge } from '../ui/badge' import { Button } from '../ui/button' import { Tooltip, TooltipTrigger, TooltipPopup, TooltipProvider } from '../ui/tooltip' -import { SQLDefinition } from './schema/shared' +import { SQLDefinition } from './schema' import { FunctionDefinitionModal } from './FunctionDefinitionModal' import { FunctionArgumentList } from './FunctionArgumentForm' import { parseFunctionArguments } from '@/lib/sql/parse-function-args' diff --git a/src/components/sql-editor/RightPanel.tsx b/src/components/sql-editor/RightPanel.tsx index 2988821..148c863 100644 --- a/src/components/sql-editor/RightPanel.tsx +++ b/src/components/sql-editor/RightPanel.tsx @@ -1,6 +1,7 @@ import { cn } from '@/lib/utils' import { Chat } from './Chat' import { ContextPanel } from './ContextPanel' +import { MigrationPanel } from './schema' import type { SelectedObject } from './SQLEditorLayout' import type { PanelTab } from './hooks/useEditorTabs' import type { ObjectType } from './ObjectTree' @@ -53,11 +54,25 @@ export function RightPanel({ open, width, activeTab, onActiveTabChange, connecti > Chat +
{activeTab === 'context' ? ( + ) : activeTab === 'migration' ? ( + ) : ( )} diff --git a/src/components/sql-editor/hooks/useEditorTabs.ts b/src/components/sql-editor/hooks/useEditorTabs.ts index 8adb2a4..f7a7de2 100644 --- a/src/components/sql-editor/hooks/useEditorTabs.ts +++ b/src/components/sql-editor/hooks/useEditorTabs.ts @@ -54,7 +54,7 @@ export interface TabState { foldedRanges?: string[] // Array of "from:to" strings for folded regions } -export type PanelTab = 'context' | 'chat' +export type PanelTab = 'context' | 'chat' | 'migration' export interface RightPanelState { open: boolean diff --git a/src/components/sql-editor/schema/MigrationPanel.tsx b/src/components/sql-editor/schema/MigrationPanel.tsx new file mode 100644 index 0000000..27e086f --- /dev/null +++ b/src/components/sql-editor/schema/MigrationPanel.tsx @@ -0,0 +1,288 @@ +import { useState } from 'react' +import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog' +import { GitBranch, Play, RefreshCw, AlertTriangle, CircleCheck } from 'lucide-react' +import { Button } from '../../ui/button' +import { Badge } from '../../ui/badge' +import { ScrollArea } from '../../ui/scroll-area' +import { Spinner } from '../../ui/spinner' +import { usePlanMigration, useApplyMigration, useSchemaSourceStatus } from '../../../hooks/useMigration' +import { useConnectionPermissions } from '../../../hooks/usePermissions' +import { useQueryClient } from '@tanstack/react-query' +import { invalidateSchemaQueries } from '../../../hooks/useQuery' +import type { SchemaDiff } from '@/gen/migration_pb' + +interface MigrationPanelProps { + connectionId: string +} + +const operationColor: Record = { + create: 'text-green-700 bg-green-50', + alter: 'text-blue-700 bg-blue-50', + drop: 'text-red-700 bg-red-50', +} + +const operationIcon: Record = { + create: '+', + alter: '~', + drop: '-', +} + +function DiffItem({ diff }: { diff: SchemaDiff }) { + const colorClass = operationColor[diff.operation] || 'text-gray-700 bg-gray-50' + const icon = operationIcon[diff.operation] || '?' + + return ( +
+ {icon} + {diff.path} + + {diff.operation.toUpperCase()} + + {diff.type} +
+ ) +} + +type PanelState = + | { kind: 'idle' } + | { kind: 'planning' } + | { kind: 'plan-error'; error: string } + | { kind: 'up-to-date'; plan: { branch: string; commitHash: string } } + | { kind: 'has-diffs'; plan: { branch: string; commitHash: string; summary: string; canRunInTransaction: boolean }; diffs: SchemaDiff[] } + | { kind: 'applying' } + | { kind: 'apply-error'; error: string } + | { kind: 'apply-success' } + +function derivePanelState( + planMutation: ReturnType, + applyMutation: ReturnType, +): PanelState { + if (applyMutation.isPending) return { kind: 'applying' } + if (applyMutation.isError) return { kind: 'apply-error', error: applyMutation.error.message } + if (applyMutation.isSuccess) return { kind: 'apply-success' } + if (planMutation.isPending) return { kind: 'planning' } + if (planMutation.isError) return { kind: 'plan-error', error: planMutation.error.message } + if (planMutation.data) { + const plan = planMutation.data + if (plan.diffs.length === 0) return { kind: 'up-to-date', plan } + return { kind: 'has-diffs', plan, diffs: plan.diffs } + } + return { kind: 'idle' } +} + +function StatusMessage({ icon, text, className }: { icon: React.ReactNode; text: string; className: string }) { + return ( +
+ {icon} + {text} +
+ ) +} + +export function MigrationPanel({ connectionId }: MigrationPanelProps) { + const { hasDdl } = useConnectionPermissions(connectionId) + const queryClient = useQueryClient() + const statusQuery = useSchemaSourceStatus(connectionId) + const planMutation = usePlanMigration() + const applyMutation = useApplyMigration() + const [showSql, setShowSql] = useState(false) + const [confirmOpen, setConfirmOpen] = useState(false) + + if (statusQuery.isLoading) { + return ( +
+ + Checking configuration... +
+ ) + } + + if (!statusQuery.data?.configured) { + return ( +
+ } text="Schema migration not configured" className="text-gray-500" /> +

+ Add a schema_source block to this connection in pgconsole.toml to enable migrations. +

+
+ ) + } + + const state = derivePanelState(planMutation, applyMutation) + + const handlePlan = () => { + applyMutation.reset() + planMutation.mutate(connectionId) + } + + const handleApply = () => { + if (!planMutation.data) return + setConfirmOpen(false) + applyMutation.mutate( + { connectionId, planId: planMutation.data.planId }, + { + onSuccess: () => { + invalidateSchemaQueries(queryClient, connectionId) + planMutation.reset() + }, + }, + ) + } + + switch (state.kind) { + case 'idle': + return ( +
+ } text="Compare current database schema with git source" className="text-gray-600" /> + +
+ ) + + case 'planning': + return ( +
+ + Analyzing schema differences... +
+ ) + + case 'plan-error': + return ( +
+ } text="Failed to generate plan" className="text-red-600" /> +

{state.error}

+ +
+ ) + + case 'up-to-date': + return ( +
+ } text="Schema is up to date with git" className="text-green-600" /> +

+ Branch: {state.plan.branch} · Commit: {state.plan.commitHash.slice(0, 7)} +

+ +
+ ) + + case 'applying': + return ( +
+ + Applying migration... +
+ ) + + case 'apply-error': + return ( +
+ } text="Migration failed" className="text-red-600" /> +

{state.error}

+ +
+ ) + + case 'apply-success': + return ( +
+ } text="Migration applied successfully" className="text-green-600" /> + +
+ ) + + case 'has-diffs': + return ( +
+
+
+ {state.plan.summary} +
+ + {hasDdl && ( + + )} +
+
+

+ Branch: {state.plan.branch} · Commit: {state.plan.commitHash.slice(0, 7)} +

+
+ + +
+ {state.diffs.map((diff, i) => ( + + ))} +
+
+ +
+ + {showSql && ( +
+
+                  {state.diffs.map(d => d.sql).join('\n\n')}
+                
+
+ )} +
+ + + + + + + + Apply Migration + + + This will execute {state.diffs.length} DDL statement{state.diffs.length > 1 ? 's' : ''} against the database. + {!state.plan.canRunInTransaction && ( + + Warning: Some operations cannot run in a transaction and will be applied individually. + + )} + +
+ + +
+
+
+
+
+
+ ) + } +} diff --git a/src/components/sql-editor/schema/index.ts b/src/components/sql-editor/schema/index.ts index f173eca..cef1840 100644 --- a/src/components/sql-editor/schema/index.ts +++ b/src/components/sql-editor/schema/index.ts @@ -1,4 +1,5 @@ export { TableSchemaContent } from './TableSchemaContent' export { ViewSchemaContent } from './ViewSchemaContent' export { FunctionSchemaContent } from './FunctionSchemaContent' +export { MigrationPanel } from './MigrationPanel' export * from './shared' diff --git a/src/hooks/useMigration.ts b/src/hooks/useMigration.ts new file mode 100644 index 0000000..15a8129 --- /dev/null +++ b/src/hooks/useMigration.ts @@ -0,0 +1,41 @@ +import { useQuery, useMutation } from '@tanstack/react-query' +import { migrationClient } from '@/lib/connect-client' + +export function useSchemaSourceStatus(connectionId: string) { + return useQuery({ + queryKey: ['migration', 'schema-source-status', connectionId], + queryFn: () => migrationClient.getSchemaSourceStatus({ connectionId }), + enabled: !!connectionId, + }) +} + +export function usePlanMigration() { + return useMutation({ + mutationFn: (connectionId: string) => + migrationClient.planMigration({ connectionId }), + }) +} + +export function useApplyMigration() { + return useMutation({ + mutationFn: async ( + params: { connectionId: string; planId: string }, + ) => { + const results: Array<{ step: number; totalSteps: number; sql: string; status: string; error: string }> = [] + for await (const response of migrationClient.applyMigration(params)) { + results.push({ + step: response.step, + totalSteps: response.totalSteps, + sql: response.sql, + status: response.status, + error: response.error, + }) + } + const failed = results.find(r => r.status === 'failed') + if (failed) { + throw new Error(failed.error || 'Migration apply failed') + } + return results + }, + }) +} diff --git a/src/lib/connect-client.ts b/src/lib/connect-client.ts index 5f575db..6325902 100644 --- a/src/lib/connect-client.ts +++ b/src/lib/connect-client.ts @@ -3,6 +3,7 @@ import { createPromiseClient } from '@connectrpc/connect'; import { ConnectionService } from '../gen/connection_connect'; import { QueryService } from '../gen/query_connect'; import { AIService } from '../gen/ai_connect'; +import { MigrationService } from '../gen/migration_connect'; // Create transport for browser // Uses relative URLs - works with Vite proxy in dev and same-origin server in prod @@ -15,3 +16,4 @@ const transport = createConnectTransport({ export const connectionClient = createPromiseClient(ConnectionService, transport); export const queryClient = createPromiseClient(QueryService, transport); export const aiClient = createPromiseClient(AIService, transport); +export const migrationClient = createPromiseClient(MigrationService, transport); diff --git a/tests/config-schema-source.test.ts b/tests/config-schema-source.test.ts new file mode 100644 index 0000000..69e7fb9 --- /dev/null +++ b/tests/config-schema-source.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest' +import { loadConfigFromString } from '../server/lib/config' +import { getConnections } from '../server/lib/config' + +describe('schema_source config parsing', () => { + it('parses connection without schema_source', async () => { + await loadConfigFromString(` +[[connections]] +id = "local" +name = "Local" +host = "localhost" +port = 5432 +database = "postgres" +username = "postgres" +`) + const conns = getConnections() + expect(conns[0].schema_source).toBeUndefined() + }) + + it('parses connection with full schema_source', async () => { + await loadConfigFromString(` +[[connections]] +id = "staging" +name = "Staging" +host = "staging.example.com" +port = 5432 +database = "myapp" +username = "app_user" + +[connections.schema_source] +repo = "https://github.com/myorg/db-schema.git" +branch = "main" +path = "schema/main.sql" +schema = "public" +`) + const conns = getConnections() + expect(conns[0].schema_source).toEqual({ + repo: 'https://github.com/myorg/db-schema.git', + branch: 'main', + path: 'schema/main.sql', + schema: 'public', + }) + }) + + it('defaults branch to undefined and schema to public', async () => { + await loadConfigFromString(` +[[connections]] +id = "staging" +name = "Staging" +host = "staging.example.com" +port = 5432 +database = "myapp" +username = "app_user" + +[connections.schema_source] +repo = "https://github.com/myorg/db-schema.git" +path = "schema/main.sql" +`) + const conns = getConnections() + expect(conns[0].schema_source).toEqual({ + repo: 'https://github.com/myorg/db-schema.git', + branch: undefined, + path: 'schema/main.sql', + schema: 'public', + }) + }) + + it('throws when schema_source.repo is missing', async () => { + await expect(loadConfigFromString(` +[[connections]] +id = "staging" +name = "Staging" +host = "staging.example.com" +port = 5432 +database = "myapp" +username = "app_user" + +[connections.schema_source] +path = "schema/main.sql" +`)).rejects.toThrow('schema_source.repo') + }) + + it('throws when schema_source.path is missing', async () => { + await expect(loadConfigFromString(` +[[connections]] +id = "staging" +name = "Staging" +host = "staging.example.com" +port = 5432 +database = "myapp" +username = "app_user" + +[connections.schema_source] +repo = "https://github.com/myorg/db-schema.git" +`)).rejects.toThrow('schema_source.path') + }) +}) diff --git a/tests/pgschema.test.ts b/tests/pgschema.test.ts new file mode 100644 index 0000000..aead582 --- /dev/null +++ b/tests/pgschema.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest' +import { parsePlanJson } from '../server/lib/pgschema' + +describe('parsePlanJson', () => { + it('parses a plan with diffs', () => { + const json = { + schemas: { + public: { + source_fingerprint: { hash: 'abc123' }, + groups: [ + { + can_run_in_transaction: true, + steps: [ + { + sql: 'ALTER TABLE users ADD COLUMN name varchar(100);', + type: 'table', + operation: 'alter', + path: 'public.users', + }, + ], + }, + { + can_run_in_transaction: false, + steps: [ + { + sql: 'CREATE INDEX CONCURRENTLY idx_users_email ON users(email);', + type: 'index', + operation: 'create', + path: 'public.idx_users_email', + }, + ], + }, + ], + }, + }, + } + + const result = parsePlanJson(json, 'public') + expect(result.sourceFingerprint).toBe('abc123') + expect(result.diffs).toHaveLength(2) + expect(result.diffs[0]).toEqual({ + sql: 'ALTER TABLE users ADD COLUMN name varchar(100);', + type: 'table', + operation: 'alter', + path: 'public.users', + canRunInTransaction: true, + }) + expect(result.canRunInTransaction).toBe(false) + expect(result.summary).toBe('2 changes: 1 to create, 1 to alter') + }) + + it('parses an empty plan', () => { + const json = { + schemas: { + public: { + source_fingerprint: { hash: 'abc123' }, + groups: [], + }, + }, + } + + const result = parsePlanJson(json, 'public') + expect(result.diffs).toHaveLength(0) + expect(result.canRunInTransaction).toBe(true) + expect(result.summary).toBe('No changes') + }) + + it('generates correct summary with all operation types', () => { + const json = { + schemas: { + public: { + source_fingerprint: { hash: 'abc123' }, + groups: [ + { + can_run_in_transaction: true, + steps: [ + { sql: 'CREATE TABLE a ();', type: 'table', operation: 'create', path: 'public.a' }, + { sql: 'CREATE TABLE b ();', type: 'table', operation: 'create', path: 'public.b' }, + { sql: 'ALTER TABLE c ADD COLUMN x int;', type: 'table', operation: 'alter', path: 'public.c' }, + { sql: 'DROP TABLE d;', type: 'table', operation: 'drop', path: 'public.d' }, + ], + }, + ], + }, + }, + } + + const result = parsePlanJson(json, 'public') + expect(result.summary).toBe('4 changes: 2 to create, 1 to alter, 1 to drop') + }) +}) diff --git a/tests/plan-store.test.ts b/tests/plan-store.test.ts new file mode 100644 index 0000000..4e9bb6b --- /dev/null +++ b/tests/plan-store.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { storePlan, getPlan, removePlan } from '../server/lib/plan-store' + +describe('plan-store', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('stores and retrieves a plan', () => { + const planId = storePlan({ + connectionId: 'staging', + planJsonPath: '/tmp/plan.json', + planData: { schemas: {} }, + schema: 'public', + }) + + const plan = getPlan(planId) + expect(plan).toBeDefined() + expect(plan!.connectionId).toBe('staging') + expect(plan!.planJsonPath).toBe('/tmp/plan.json') + }) + + it('returns undefined for unknown plan', () => { + expect(getPlan('nonexistent')).toBeUndefined() + }) + + it('removes a plan', () => { + const planId = storePlan({ + connectionId: 'staging', + planJsonPath: '/tmp/plan.json', + planData: { schemas: {} }, + schema: 'public', + }) + + removePlan(planId) + expect(getPlan(planId)).toBeUndefined() + }) + + it('expires plans after 30 minutes', () => { + const planId = storePlan({ + connectionId: 'staging', + planJsonPath: '/tmp/plan.json', + planData: { schemas: {} }, + schema: 'public', + }) + + vi.advanceTimersByTime(31 * 60 * 1000) + + expect(getPlan(planId)).toBeUndefined() + }) + + it('returns plan before expiry', () => { + const planId = storePlan({ + connectionId: 'staging', + planJsonPath: '/tmp/plan.json', + planData: { schemas: {} }, + schema: 'public', + }) + + vi.advanceTimersByTime(29 * 60 * 1000) + + expect(getPlan(planId)).toBeDefined() + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 6369bc8..25817d2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -77,6 +77,7 @@ export default defineConfig(({ command }) => { '/connection.v1.ConnectionService': 'http://localhost:9876', '/query.v1.QueryService': 'http://localhost:9876', '/ai.v1.AIService': 'http://localhost:9876', + '/migration.v1.MigrationService': 'http://localhost:9876', }, }, }