import http from "node:http";
import chalk from "chalk";
import { getDirname } from "../esm-paths";
import {
findAvailablePort,
openBrowser,
readJsonBody,
reportStudioStartupLine,
resolveStudioDistPath,
sendJson,
sendText,
serveStudioAsset as serveStudioAssetFromKit,
getString,
getNumber,
type TJsonBody,
type TStudioLogFn,
} from "../studio-shared/studio-server-kit";
import { StudioConnectionPool } from "./db-connection";
import { classifyDatabaseError } from "./db-error";
import {
duplicateConnection,
findConnectionProfile,
getResolvedConnection,
listPublicConnections,
removeConnection,
renameConnection,
updateConnectionFields,
upsertConnectionFromDraft,
} from "./db-cache";
import type { TConnectionDraft, TConnectionFieldPatch } from "./db-cache";
import { testConnectionProfile } from "./db-connection";
import {
analyzeSqlSafety,
appendSafeLimit,
generateCountSql,
generateCreateTableDdl,
generateSelectSql,
looksLikeProduction,
} from "./db-metadata";
import type { TGridSortState, TResolvedDatabaseConnection, TStudioWorkspaceState, TTableChangeSet } from "./db-types";
import {
deleteSavedQuery,
listSavedQueries,
renameSavedQuery,
saveQuery,
} from "./db-query-files";
import { deleteRow, insertRow, saveTableChanges, updateRow } from "./db-row";
import { appendQueryHistory, listQueryHistory } from "./db-query-history";
import { readWorkspace, writeWorkspace } from "./studio/workspace-cache";
import { readStudioSettings, writeStudioSettings } from "./studio/studio-settings";
import {
formatSql,
generateInsertTemplate,
generateTableQuery,
generateUpdateTemplate,
splitStatements,
} from "./studio/sql-formatter";
import {
buildDraftFromCandidate,
detectAppDatabaseServices,
detectAppDatabaseServicesInContext,
ensureCloudFoundrySession,
getCloudFoundryTargetSummary,
importConnectionFromApp,
importConnectionFromAppInContext,
listAppsInContext,
} from "./db-btp";
import { onCacheEvent, formatRelativeTime, computeCacheStatus, refreshCache, DEFAULT_CACHE_TTL } from "../cache/smart-cache";
import { readAllEntries, readEntry } from "../cache/smart-cache-store";
import { listFavoriteTargets, listRecentTargets, addFavoriteTarget, removeFavoriteTarget, addRecentTarget } from "../cf/cf-target-cache";
import { cfTargetKey, isValidCfTarget } from "../cf/cf-target.types";
import type { TCfTarget } from "../cf/cf-target.types";
import { listCrossRegionTargets, listCrossRegionOrgSummaries, getCrossRegionStatus, scanCrossRegionTargets } from "../cf/cf-cross-region-scanner";
import type { TCfScanCredential } from "../cf/cf-cross-region-scanner";
import { withCfTarget, parseCfTargetKey } from "../cf/cf-target-switcher";
import { getCfAuthStatus, loginCfWithPassword, cfLogout } from "../cf/cf-auth-service";
import { setCfDebug } from "../cf/cf-execution-service";
import { listRegions } from "../cf/cf-region-registry";
import { readCache } from "../cache";
import type { TDatabaseErrorInfo, TDatabaseErrorKind, TDatabaseObjectKind, TDatabaseType } from "./db-types";
import type { TSmartCacheEntry } from "../cache/smart-cache.types";
export type TStudioServerOptions = {
port?: number;
readOnly?: boolean;
queryTimeoutMs?: number;
debugCf?: boolean;
/** Serve only the JSON/SSE API — no static UI, no browser auto-open. Used by `--dev-ui`/`--api-only` so a separately-run Vite dev server owns the frontend. */
apiOnly?: boolean;
onLog?: TStudioLogFn;
};
const __dirname = getDirname(import.meta.url);
const STUDIO_NOT_BUILT_HTML =
"
DB Studio UI is not built
" +
"Run npm run build:studio (or npm run build) from the repository root, then restart smdg db studio.
" +
"For frontend development, run smdg db studio --dev-ui and follow the printed instructions.
";
/** Serve the built React Studio (SPA fallback: unknown paths without a file extension resolve to index.html). */
async function serveStudioAsset(pathname: string, res: http.ServerResponse): Promise {
await serveStudioAssetFromKit({
distPath: await resolveStudioDistPath(__dirname),
pathname,
res,
fallbackHtmlFileName: "index.html",
notBuiltMessageHtml: STUDIO_NOT_BUILT_HTML,
});
}
export type TStudioServerHandle = {
url: string;
port: number;
close: () => Promise;
};
function toCsv(fields: string[], rows: Array>): string {
const escapeCell = (value: unknown): string => {
const text = value === null || value === undefined ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
return `"${text.replace(/"/g, '""')}"`;
};
const header = fields.map(escapeCell).join(",");
const lines = rows.map((row) => fields.map((field) => escapeCell(row[field])).join(","));
return [header, ...lines].join("\n");
}
const VALID_ENVIRONMENTS = new Set(["DEV", "QAS", "PROD", "SANDBOX", "CUSTOM"]);
function getEnvironment(body: TJsonBody): TConnectionDraft["environment"] {
const value = getString(body, "environment").toUpperCase();
return VALID_ENVIRONMENTS.has(value) ? (value as TConnectionDraft["environment"]) : undefined;
}
/** Detect environment label from org/space name (heuristic). */
function detectEnvironment(org: string, space: string): string {
const haystack = `${org} ${space}`.toLowerCase();
if (/\bprod\b|production|prd|\blive\b/.test(haystack)) return "PROD";
if (/\bqas\b|quality|staging|uat/.test(haystack)) return "QAS";
if (/\bdev\b|development|\blocal\b/.test(haystack)) return "DEV";
if (/\bsandbox\b|sbx/.test(haystack)) return "SANDBOX";
return "";
}
/**
* Build a serialisable summary for a cached target from the cf-apps namespace
* entry (or just target metadata if no apps are cached).
*/
function buildTargetSummary(target: TCfTarget, appsEntry?: TSmartCacheEntry | undefined): Record {
const env = detectEnvironment(target.org, target.space);
const cacheStatus = appsEntry ? computeCacheStatus(appsEntry) : "missing";
const appCount = appsEntry ? (Array.isArray(appsEntry.data) ? appsEntry.data.length : 0) : undefined;
return {
region: target.region,
apiEndpoint: target.apiEndpoint,
org: target.org,
space: target.space,
key: cfTargetKey(target),
isFavorite: target.isFavorite ?? false,
lastUsedAt: target.lastUsedAt,
environment: env,
cachedAppCount: appCount,
cacheStatus,
updatedAt: appsEntry?.updatedAt,
updatedAgo: formatRelativeTime(appsEntry?.updatedAt),
};
}
/** Resolve cached CF login credentials for the cross-region scanner. */
async function resolveCfScanCredentials(): Promise {
const cache = await readCache();
return cache.cloudFoundry.loginProfiles.map((profile) => ({
apiEndpoint: profile.apiEndpoint,
username: profile.username,
password: profile.password,
}));
}
function draftFromBody(body: TJsonBody): TConnectionDraft {
const type = getString(body, "type") === "hana" ? "hana" : "postgresql";
return {
name: getString(body, "name") || `${type} connection`,
color: getString(body, "color") || undefined,
environment: getEnvironment(body),
isFavorite: body.isFavorite === undefined ? undefined : Boolean(body.isFavorite),
type,
host: getString(body, "host"),
port: getNumber(body, "port", type === "hana" ? 443 : 5432),
database: getString(body, "database") || undefined,
schema: getString(body, "schema") || undefined,
username: getString(body, "username"),
password: getString(body, "password"),
ssl: body.ssl === undefined ? true : Boolean(body.ssl),
sslValidateCertificate: Boolean(body.sslValidateCertificate),
tags: Array.isArray(body.tags) ? (body.tags as string[]) : undefined,
};
}
function getObject(body: TJsonBody, key: string): Record {
const value = body[key];
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {};
}
function resolvedFromDraft(draft: TConnectionDraft): TResolvedDatabaseConnection {
const now = new Date().toISOString();
return {
id: "draft",
name: draft.name,
type: draft.type,
host: draft.host,
port: draft.port,
database: draft.database,
schema: draft.schema,
username: draft.username,
password: draft.password,
ssl: draft.ssl,
sslValidateCertificate: draft.sslValidateCertificate,
createdAt: now,
updatedAt: now,
};
}
export async function startStudioServer(options: TStudioServerOptions = {}): Promise {
// Background CF work runs silently unless debug mode is explicitly enabled.
setCfDebug(options.debugCf ?? false);
const preferredPort = options.port && options.port > 0 ? options.port : 45888;
const port = await findAvailablePort(preferredPort);
const pool = new StudioConnectionPool({ queryTimeoutMs: options.queryTimeoutMs });
const serverReadOnlyDefault = options.readOnly ?? false;
// HTTP status per DB error kind: 503 for transient connectivity issues (the
// caller can safely retry), 401/403 for credential/authorization problems,
// 400 for bad input (SQL syntax), 500 reserved for truly unexpected bugs.
const statusForErrorKind = (kind: TDatabaseErrorKind): number => {
switch (kind) {
case "network":
case "timeout":
return 503;
case "authentication":
case "stale-credential":
return 401;
case "permission":
return 403;
case "syntax":
return 400;
default:
return 500;
}
};
// Only offer recovery actions that can plausibly fix that class of error —
// e.g. retrying a syntax error changes nothing, so it gets none.
const recoveryActionsForErrorKind = (kind: TDatabaseErrorKind): Array<"retry" | "reconnect" | "refresh-from-btp" | "close-connection"> => {
switch (kind) {
case "network":
case "timeout":
return ["retry", "reconnect", "refresh-from-btp"];
case "authentication":
case "stale-credential":
return ["reconnect", "refresh-from-btp", "close-connection"];
case "permission":
return ["reconnect", "close-connection"];
case "syntax":
return [];
default:
return ["retry", "close-connection"];
}
};
// Resolve the classified error for a connection (the pool records it on the
// connection state) into the recovery-aware shape the UI expects, plus the
// HTTP status to send it with.
const buildAdapterError = (connectionId: string, error: unknown): {
payload: { error: string; errorInfo: TDatabaseErrorInfo; recoveryActions: string[] };
status: number;
} => {
const state = pool.getConnectionStatus(connectionId);
// Prefer the pool's classification (it knows the adapter type, e.g. HANA
// vs PostgreSQL, for a more specific message); fall back to classifying
// the caught error directly if no state was ever recorded for this id
// (e.g. the connection profile itself couldn't be resolved).
const errorInfo: TDatabaseErrorInfo = state?.lastError ?? classifyDatabaseError(error, "postgresql");
return {
payload: { error: errorInfo.message, errorInfo, recoveryActions: recoveryActionsForErrorKind(errorInfo.kind) },
status: statusForErrorKind(errorInfo.kind),
};
};
const sendAdapterError = (res: http.ServerResponse, connectionId: string, error: unknown): void => {
const built = buildAdapterError(connectionId, error);
sendJson(res, built.payload, built.status);
};
const router = async (req: http.IncomingMessage, res: http.ServerResponse): Promise => {
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
const pathname = url.pathname;
const method = req.method ?? "GET";
if (pathname === "/" && method === "GET") {
if (options.apiOnly) {
sendJson(res, { error: "This server is running in --api-only mode. Start the Vite dev server separately: cd studio && npm run dev" }, 404);
return;
}
await serveStudioAsset(pathname, res);
return;
}
// Server-Sent Events: stream smart-cache background-refresh notifications.
if (pathname === "/api/events" && method === "GET") {
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "keep-alive",
});
res.write(": connected\n\n");
const unsubscribe = onCacheEvent((event) => {
res.write(`data: ${JSON.stringify(event)}\n\n`);
});
const keepAlive = setInterval(() => res.write(": ping\n\n"), 25000);
req.on("close", () => {
clearInterval(keepAlive);
unsubscribe();
});
return;
}
// --- Connections ---------------------------------------------------------
if (pathname === "/api/connections" && method === "GET") {
sendJson(res, { connections: await listPublicConnections() });
return;
}
if (pathname === "/api/connections/test" && method === "POST") {
const body = await readJsonBody(req);
const resolved = await getResolvedConnection(getString(body, "connectionId"));
const result = await testConnectionProfile(resolved, { queryTimeoutMs: options.queryTimeoutMs });
sendJson(res, result);
return;
}
if (pathname === "/api/connections/test-draft" && method === "POST") {
const body = await readJsonBody(req);
const result = await testConnectionProfile(resolvedFromDraft(draftFromBody(body)), { queryTimeoutMs: options.queryTimeoutMs });
sendJson(res, result);
return;
}
if (pathname === "/api/connections/create" && method === "POST") {
const body = await readJsonBody(req);
const profile = await upsertConnectionFromDraft(draftFromBody(body));
const { encryptedPassword: _omit, ...publicProfile } = profile;
void _omit;
sendJson(res, { connection: publicProfile });
return;
}
if (pathname === "/api/connections/rename" && method === "POST") {
const body = await readJsonBody(req);
const profile = await renameConnection(getString(body, "id"), getString(body, "name"));
sendJson(res, { id: profile.id, name: profile.name });
return;
}
if (pathname === "/api/connections/update" && method === "POST") {
const body = await readJsonBody(req);
const patch: TConnectionFieldPatch = {};
if (typeof body.name === "string") patch.name = body.name;
if (typeof body.color === "string") patch.color = body.color;
if (body.environment !== undefined) patch.environment = getEnvironment(body);
if (body.isFavorite !== undefined) patch.isFavorite = Boolean(body.isFavorite);
if (Array.isArray(body.tags)) patch.tags = body.tags as string[];
const profile = await updateConnectionFields(getString(body, "id"), patch);
const { encryptedPassword: _omit, ...publicProfile } = profile;
void _omit;
sendJson(res, { connection: publicProfile });
return;
}
if (pathname === "/api/connections/duplicate" && method === "POST") {
const body = await readJsonBody(req);
const profile = await duplicateConnection(getString(body, "id"));
sendJson(res, { id: profile.id, name: profile.name });
return;
}
if (pathname === "/api/connections/remove" && method === "POST") {
const body = await readJsonBody(req);
await pool.closeConnection(getString(body, "id"));
const removed = await removeConnection(getString(body, "id"));
sendJson(res, { removed });
return;
}
if (pathname === "/api/connections/import-from-app" && method === "POST") {
const body = await readJsonBody(req);
const targetKey = getString(body, "targetKey");
const importArgs = {
app: getString(body, "app"),
serviceName: getString(body, "serviceName") || undefined,
type: (getString(body, "type") || undefined) as TDatabaseType | undefined,
};
// When a cross-region targetKey is supplied, run the import (cf env) under
// that target's isolated CF_HOME and record its region/org/space.
const { profile } = targetKey
? await withCfTarget(targetKey, (context, target) => importConnectionFromAppInContext(context, {
...importArgs,
target: { region: target.region, org: target.org, space: target.space },
}))
: await importConnectionFromApp(importArgs);
const { encryptedPassword: _omitPassword, ...publicProfile } = profile;
void _omitPassword;
sendJson(res, { connection: publicProfile });
return;
}
// --- Connection lifecycle ------------------------------------------------
if (pathname === "/api/connections/reconnect" && method === "POST") {
const body = await readJsonBody(req);
const id = getString(body, "connectionId");
const result = await pool.reconnectConnection(id);
const state = pool.getConnectionStatus(id);
sendJson(res, { ...result, status: state?.status, errorInfo: state?.lastError });
return;
}
if (pathname === "/api/connections/close" && method === "POST") {
const body = await readJsonBody(req);
await pool.invalidateConnection(getString(body, "connectionId"));
sendJson(res, { ok: true });
return;
}
if (pathname === "/api/connections/status" && method === "GET") {
const id = url.searchParams.get("connectionId");
if (id) {
const state = pool.getConnectionStatus(id);
sendJson(res, { connectionId: id, status: state?.status ?? "disconnected", lastUsedAt: state?.lastUsedAt, errorInfo: state?.lastError });
} else {
sendJson(res, { statuses: pool.listConnectionStatuses() });
}
return;
}
if (pathname === "/api/connections/refresh-from-btp" && method === "POST") {
const body = await readJsonBody(req);
const id = getString(body, "connectionId");
const conn = await findConnectionProfile(id);
if (!conn) {
sendJson(res, { ok: false, error: "Connection not found." });
return;
}
if (!conn.app || !conn.region || !conn.org || !conn.space) {
sendJson(res, { ok: false, error: "This connection was not imported from a BTP app (missing region/org/space/app)." });
return;
}
const targetKey = `${conn.region}::${conn.org}::${conn.space}`;
try {
// Re-read cf env under the connection's isolated target and refresh the
// encrypted credentials in place, preserving name/color/environment.
const candidates = await withCfTarget(targetKey, (context) => detectAppDatabaseServicesInContext(context, conn.app as string));
const chosen = conn.serviceName
? candidates.find((candidate) => candidate.serviceName === conn.serviceName && candidate.type === conn.type)
: candidates[0];
if (!chosen) {
sendJson(res, { ok: false, error: `Service '${conn.serviceName ?? ""}' was not found in ${conn.app} env.` });
return;
}
const draft = buildDraftFromCandidate(chosen, { region: conn.region, org: conn.org, space: conn.space, app: conn.app });
draft.id = conn.id;
draft.name = conn.name;
draft.environment = conn.environment;
draft.color = conn.color;
draft.isFavorite = conn.isFavorite;
const profile = await upsertConnectionFromDraft(draft);
await pool.invalidateConnection(id);
const test = await pool.testConnection(id).catch((error) => ({ success: false, message: error instanceof Error ? error.message : String(error), durationMs: 0 }));
const { encryptedPassword: _omitRefresh, ...publicProfile } = profile;
void _omitRefresh;
sendJson(res, { ok: true, connection: publicProfile, test });
} catch (error) {
sendJson(res, { ok: false, error: error instanceof Error ? error.message : String(error) });
}
return;
}
// --- CF Auth -------------------------------------------------------------
if (pathname === "/api/cf/auth-status" && method === "GET") {
const status = await getCfAuthStatus();
sendJson(res, status);
return;
}
if (pathname === "/api/cf/login" && method === "POST") {
const body = await readJsonBody(req);
const result = await loginCfWithPassword({
apiEndpoint: getString(body, "apiEndpoint"),
region: getString(body, "region") || undefined,
username: getString(body, "username"),
password: getString(body, "password"),
remember: body.remember !== false,
});
if (result.success) {
// Kick off a background cross-region scan so the wizard populates after login.
void scanCrossRegionTargets({ credentials: await resolveCfScanCredentials() }).catch(() => undefined);
}
sendJson(res, result);
return;
}
if (pathname === "/api/cf/logout" && method === "POST") {
const body = await readJsonBody(req);
const result = await cfLogout({ clearCachedCredentials: body.clearCachedCredentials === true });
sendJson(res, result);
return;
}
if (pathname === "/api/cf/regions" && method === "GET") {
const regions = await listRegions();
sendJson(res, { regions: regions.filter((r) => r.enabled || r.isCustom) });
return;
}
// --- BTP -----------------------------------------------------------------
// -------- Multi-target BTP routes (smart cache first) -------------------
if (pathname === "/api/btp/targets" && method === "GET") {
const favTargets = await listFavoriteTargets();
const recentTargets = await listRecentTargets(10);
const appsEntries = await readAllEntries("cf-apps");
const favKeys = new Set(favTargets.map((t) => cfTargetKey(t)));
// Primary source: cross-region target cache. Fall back to keys seen in the
// cf-apps cache (only valid 3-part keys with non-empty space).
const crossRegion = await listCrossRegionTargets();
const orgSummaries = await listCrossRegionOrgSummaries();
const allFromApps: TCfTarget[] = [];
for (const key of Object.keys(appsEntries)) {
const parts = key.split("::");
if (parts.length === 3 && parts[0]?.trim() && parts[1]?.trim() && parts[2]?.trim()) {
allFromApps.push({ region: parts[0], apiEndpoint: "", org: parts[1], space: parts[2] });
}
}
const targetMap = new Map();
for (const t of [...crossRegion, ...favTargets, ...recentTargets, ...allFromApps]) {
if (!isValidCfTarget(t)) continue;
const k = cfTargetKey(t);
if (!targetMap.has(k)) targetMap.set(k, t);
}
const allTargets = Array.from(targetMap.values());
const byRegion: Record = {};
for (const t of allTargets) {
if (!byRegion[t.region]) byRegion[t.region] = [];
const appsEntry = appsEntries[cfTargetKey(t)] as TSmartCacheEntry | undefined;
byRegion[t.region].push(buildTargetSummary({ ...t, isFavorite: favKeys.has(cfTargetKey(t)) }, appsEntry));
}
// Group org summaries (no-spaces / spaces-failed) by region for the UI.
const noSpaceByRegion: Record> = {};
for (const summary of orgSummaries) {
if (summary.status !== "spaces-loaded") {
if (!noSpaceByRegion[summary.region]) noSpaceByRegion[summary.region] = [];
noSpaceByRegion[summary.region].push({ org: summary.org, status: summary.status, error: summary.error });
}
}
const regionStatus = await getCrossRegionStatus();
sendJson(res, {
favorites: favTargets.map((t) => buildTargetSummary(t, appsEntries[cfTargetKey(t)] as TSmartCacheEntry | undefined)),
recent: recentTargets.map((t) => buildTargetSummary(t, appsEntries[cfTargetKey(t)] as TSmartCacheEntry | undefined)),
byRegion,
noSpaceByRegion,
totalTargets: allTargets.length,
regions: Object.keys(byRegion).sort(),
regionStatus: regionStatus.regions,
lastUpdatedAt: regionStatus.lastUpdatedAt,
lastUpdatedAgo: formatRelativeTime(regionStatus.lastUpdatedAt),
});
return;
}
if (pathname === "/api/btp/targets/refresh" && method === "POST") {
// Trigger a background cross-region scan; respond immediately. Progress is
// streamed over /api/events. Credentials are resolved server-side.
void scanCrossRegionTargets({ credentials: await resolveCfScanCredentials() }).catch(() => undefined);
sendJson(res, { ok: true, started: true });
return;
}
if (pathname === "/api/btp/apps" && method === "GET") {
const targetKey = url.searchParams.get("targetKey") ?? "";
const forceRefresh = url.searchParams.get("refresh") === "true";
if (!targetKey) { sendJson(res, { apps: [], cacheStatus: "missing", error: "targetKey required" }); return; }
let parts: { region: string; org: string; space: string };
try {
parts = parseCfTargetKey(targetKey);
} catch (error) {
sendJson(res, { apps: [], cacheStatus: "missing", error: error instanceof Error ? error.message : String(error) });
return;
}
const targetCtx = { region: parts.region, org: parts.org, space: parts.space };
const entry = await readEntry("cf-apps", targetKey);
const cacheStatus = entry ? computeCacheStatus(entry) : "missing";
// Fetcher always runs CF commands IN THE CONTEXT of the selected target
// (isolated CF_HOME via the execution service).
const fetchApps = () => withCfTarget(targetKey, (context) => listAppsInContext(context));
// Cache-first: return cached apps immediately and refresh in background.
if (entry && !forceRefresh) {
const refreshPromise = refreshCache({ namespace: "cf-apps", key: targetKey, ttlMs: DEFAULT_CACHE_TTL.cfApps, resource: "cf-apps", fetcher: fetchApps });
refreshPromise.catch(() => undefined);
sendJson(res, { targetKey, target: targetCtx, apps: entry.data, cacheStatus, fromCache: true, isRefreshing: true, updatedAt: entry.updatedAt, updatedAgo: formatRelativeTime(entry.updatedAt) });
return;
}
// No cache (or forced): switch to target and fetch live.
try {
const apps = await refreshCache({ namespace: "cf-apps", key: targetKey, ttlMs: DEFAULT_CACHE_TTL.cfApps, resource: "cf-apps", fetcher: fetchApps });
sendJson(res, { targetKey, target: targetCtx, apps, cacheStatus: "fresh", fromCache: false, isRefreshing: false, updatedAt: new Date().toISOString() });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (entry) {
sendJson(res, { targetKey, target: targetCtx, apps: entry.data, cacheStatus: "stale", fromCache: true, isRefreshing: false, updatedAt: entry.updatedAt, updatedAgo: formatRelativeTime(entry.updatedAt), warning: `Refresh failed; showing cached apps. ${message}` });
} else {
sendJson(res, { targetKey, target: targetCtx, apps: [], cacheStatus: "missing", fromCache: false, isRefreshing: false, error: message });
}
}
return;
}
if (pathname === "/api/btp/db-candidates" && method === "GET") {
const targetKey = url.searchParams.get("targetKey") ?? "";
const appName = url.searchParams.get("appName") ?? "";
if (!targetKey || !appName) { sendJson(res, { candidates: [], cacheStatus: "missing", error: "targetKey and appName required" }); return; }
let parts: { region: string; org: string; space: string };
try {
parts = parseCfTargetKey(targetKey);
} catch (error) {
sendJson(res, { candidates: [], cacheStatus: "missing", error: error instanceof Error ? error.message : String(error) });
return;
}
const targetCtx = { region: parts.region, org: parts.org, space: parts.space };
const candidateKey = `${targetKey}::${appName}`;
const forceRefresh = url.searchParams.get("refresh") === "true";
const entry = await readEntry("db-import-candidates", candidateKey);
// DB candidates are fetched under the selected target; passwords stripped.
const fetchCandidates = async () => {
const candidates = await withCfTarget(targetKey, (context) => detectAppDatabaseServicesInContext(context, appName));
return candidates.map(({ password: _p, ...rest }) => { void _p; return rest; });
};
if (entry && !forceRefresh) {
const refreshPromise = refreshCache({ namespace: "db-import-candidates", key: candidateKey, ttlMs: DEFAULT_CACHE_TTL.dbImportCandidates, resource: "db-import-candidates", fetcher: fetchCandidates });
refreshPromise.catch(() => undefined);
sendJson(res, { targetKey, target: targetCtx, appName, candidates: entry.data, cacheStatus: computeCacheStatus(entry), fromCache: true, isRefreshing: true, updatedAt: entry.updatedAt, updatedAgo: formatRelativeTime(entry.updatedAt) });
return;
}
try {
const candidates = await refreshCache({ namespace: "db-import-candidates", key: candidateKey, ttlMs: DEFAULT_CACHE_TTL.dbImportCandidates, resource: "db-import-candidates", fetcher: fetchCandidates });
sendJson(res, { targetKey, target: targetCtx, appName, candidates, cacheStatus: "fresh", fromCache: false, isRefreshing: false, updatedAt: new Date().toISOString() });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (entry) {
sendJson(res, { targetKey, target: targetCtx, appName, candidates: entry.data, cacheStatus: "stale", fromCache: true, isRefreshing: false, updatedAt: entry.updatedAt, updatedAgo: formatRelativeTime(entry.updatedAt), warning: `Refresh failed; showing cached candidates. ${message}` });
} else {
sendJson(res, { targetKey, target: targetCtx, appName, candidates: [], cacheStatus: "missing", fromCache: false, isRefreshing: false, error: message });
}
}
return;
}
if (pathname === "/api/btp/favorite" && method === "POST") {
const body = await readJsonBody(req);
const targetKey = getString(body, "targetKey");
const add = body.add !== false;
const parts = targetKey.split("::");
const target: TCfTarget = { region: parts[0] ?? "", apiEndpoint: "", org: parts[1] ?? "", space: parts[2] ?? "" };
if (add) await addFavoriteTarget(target); else await removeFavoriteTarget(target);
sendJson(res, { ok: true });
return;
}
if (pathname === "/api/btp/recent" && method === "POST") {
const body = await readJsonBody(req);
const targetKey = getString(body, "targetKey");
const parts = targetKey.split("::");
const target: TCfTarget = { region: parts[0] ?? "", apiEndpoint: "", org: parts[1] ?? "", space: parts[2] ?? "" };
await addRecentTarget(target);
sendJson(res, { ok: true });
return;
}
// -------- Legacy BTP routes (current CF target) --------------------------
if (pathname === "/api/btp/current-target" && method === "GET") {
const session = await ensureCloudFoundrySession();
const target = await getCloudFoundryTargetSummary();
sendJson(res, {
loggedIn: session.loggedIn,
message: session.message,
target,
productionWarning: looksLikeProduction(target.org, target.space),
});
return;
}
if (pathname === "/api/btp/env" && method === "POST") {
const body = await readJsonBody(req);
const candidates = await detectAppDatabaseServices(getString(body, "app"));
// Never expose passwords to the browser.
const safeCandidates = candidates.map(({ password: _password, ...rest }) => {
void _password;
return rest;
});
sendJson(res, { services: safeCandidates });
return;
}
// --- Catalog -------------------------------------------------------------
// Read-only catalog/table routes run via runWithAdapter so a dropped socket
// is reconnected and the read retried once. On failure they return a
// structured error with recovery actions for the object explorer.
if (pathname === "/api/catalog/schemas" && method === "GET") {
const connectionId = url.searchParams.get("connectionId") ?? "";
try {
const schemas = await pool.runWithAdapter(connectionId, (adapter) => adapter.listSchemas(), { retryReadOnlyOnNetworkError: true });
sendJson(res, { schemas });
} catch (error) {
sendAdapterError(res, connectionId, error);
}
return;
}
if (pathname === "/api/catalog/objects" && method === "GET") {
const connectionId = url.searchParams.get("connectionId") ?? "";
const kindsParam = url.searchParams.get("kinds");
const kinds = kindsParam ? (kindsParam.split(",").filter(Boolean) as TDatabaseObjectKind[]) : undefined;
try {
const objects = await pool.runWithAdapter(connectionId, (adapter) => adapter.listObjects({
schema: url.searchParams.get("schema") ?? undefined,
search: url.searchParams.get("search") ?? undefined,
kinds,
}), { retryReadOnlyOnNetworkError: true });
sendJson(res, { objects });
} catch (error) {
sendAdapterError(res, connectionId, error);
}
return;
}
if (pathname === "/api/catalog/columns" && method === "GET") {
const connectionId = url.searchParams.get("connectionId") ?? "";
const schema = url.searchParams.get("schema") ?? "";
const table = url.searchParams.get("table") ?? "";
try {
const result = await pool.runWithAdapter(connectionId, async (adapter) => {
const [columns, indexes] = await Promise.all([
adapter.listColumns(schema, table),
adapter.listIndexes(schema, table).catch(() => []),
]);
return { columns, indexes };
}, { retryReadOnlyOnNetworkError: true });
sendJson(res, result);
} catch (error) {
sendAdapterError(res, connectionId, error);
}
return;
}
if (pathname === "/api/catalog/ddl" && method === "GET") {
const connectionId = url.searchParams.get("connectionId") ?? "";
const schema = url.searchParams.get("schema") ?? "";
const table = url.searchParams.get("table") ?? "";
try {
const ddl = await pool.runWithAdapter(connectionId, async (adapter) => {
const columns = await adapter.listColumns(schema, table);
return generateCreateTableDdl(adapter.type, schema, table, columns);
}, { retryReadOnlyOnNetworkError: true });
sendJson(res, { ddl });
} catch (error) {
sendAdapterError(res, connectionId, error);
}
return;
}
if (pathname === "/api/catalog/indexes" && method === "GET") {
const connectionId = url.searchParams.get("connectionId") ?? "";
try {
const indexes = await pool.runWithAdapter(connectionId, (adapter) => adapter.listIndexes(url.searchParams.get("schema") ?? "", url.searchParams.get("table") ?? ""), { retryReadOnlyOnNetworkError: true });
sendJson(res, { indexes });
} catch (error) {
sendAdapterError(res, connectionId, error);
}
return;
}
if (pathname === "/api/catalog/primary-key" && method === "GET") {
const connectionId = url.searchParams.get("connectionId") ?? "";
try {
const primaryKey = await pool.runWithAdapter(connectionId, (adapter) => adapter.getPrimaryKey(url.searchParams.get("schema") ?? "", url.searchParams.get("table") ?? ""), { retryReadOnlyOnNetworkError: true });
sendJson(res, { primaryKey });
} catch (error) {
sendAdapterError(res, connectionId, error);
}
return;
}
if (pathname === "/api/catalog/constraints" && method === "GET") {
const connectionId = url.searchParams.get("connectionId") ?? "";
const schema = url.searchParams.get("schema") ?? "";
const table = url.searchParams.get("table") ?? "";
try {
const result = await pool.runWithAdapter(connectionId, async (adapter) => {
const [primaryKey, indexes] = await Promise.all([
adapter.getPrimaryKey(schema, table),
adapter.listIndexes(schema, table).catch(() => []),
]);
return { primaryKey, indexes };
}, { retryReadOnlyOnNetworkError: true });
sendJson(res, result);
} catch (error) {
sendAdapterError(res, connectionId, error);
}
return;
}
// --- Table data ----------------------------------------------------------
if (pathname === "/api/table/data" && method === "POST") {
const body = await readJsonBody(req);
const connectionId = getString(body, "connectionId");
try {
const result = await pool.runWithAdapter(connectionId, (adapter) => adapter.getTableData({
schema: getString(body, "schema"),
table: getString(body, "table"),
limit: getNumber(body, "limit", 100),
offset: getNumber(body, "offset", 0),
where: getString(body, "where") || undefined,
orderBy: getString(body, "orderBy") || undefined,
orderDirection: getString(body, "orderDirection") === "desc" ? "desc" : "asc",
}), { retryReadOnlyOnNetworkError: true });
sendJson(res, { result });
} catch (error) {
sendAdapterError(res, connectionId, error);
}
return;
}
if (pathname === "/api/table/count" && method === "POST") {
const body = await readJsonBody(req);
const connectionId = getString(body, "connectionId");
try {
const count = await pool.runWithAdapter(connectionId, (adapter) => adapter.countRows(getString(body, "schema"), getString(body, "table")), { retryReadOnlyOnNetworkError: true });
sendJson(res, { count });
} catch (error) {
sendAdapterError(res, connectionId, error);
}
return;
}
if ((pathname === "/api/table/row/update" || pathname === "/api/table/row/insert" || pathname === "/api/table/row/delete") && method === "POST") {
const body = await readJsonBody(req);
const readOnly = body.readOnly === undefined ? serverReadOnlyDefault : Boolean(body.readOnly);
if (readOnly) {
sendJson(res, { ok: false, blocked: true, error: "Read-only mode is on. Turn it off to modify data." });
return;
}
const connectionId = getString(body, "connectionId");
const schema = getString(body, "schema");
const table = getString(body, "table");
try {
const adapter = await pool.getAdapter(connectionId);
let result;
if (pathname.endsWith("/update")) {
result = await updateRow(adapter, { schema, table, changes: getObject(body, "changes"), keys: getObject(body, "keys") });
} else if (pathname.endsWith("/insert")) {
result = await insertRow(adapter, { schema, table, values: getObject(body, "values") });
} else {
result = await deleteRow(adapter, { schema, table, keys: getObject(body, "keys") });
}
sendJson(res, { ok: true, result });
} catch (error) {
const built = buildAdapterError(connectionId, error);
sendJson(res, { ok: false, ...built.payload }, built.status);
}
return;
}
if (pathname === "/api/table/save-changes" && method === "POST") {
const body = await readJsonBody(req);
const readOnly = body.readOnly === undefined ? serverReadOnlyDefault : Boolean(body.readOnly);
if (readOnly) {
sendJson(res, { ok: false, blocked: true, error: "Read-only mode is on. Turn it off to save changes." });
return;
}
const connectionId = getString(body, "connectionId");
try {
const adapter = await pool.getAdapter(connectionId);
const result = await saveTableChanges(adapter, {
schema: getString(body, "schema"),
table: getString(body, "table"),
primaryKeyColumns: Array.isArray(body.primaryKeyColumns) ? (body.primaryKeyColumns as string[]) : [],
updates: Array.isArray(body.updates) ? (body.updates as TTableChangeSet["updates"]) : [],
inserts: Array.isArray(body.inserts) ? (body.inserts as TTableChangeSet["inserts"]) : [],
deletes: Array.isArray(body.deletes) ? (body.deletes as TTableChangeSet["deletes"]) : [],
});
sendJson(res, { ok: true, result });
} catch (error) {
const built = buildAdapterError(connectionId, error);
sendJson(res, { ok: false, ...built.payload }, built.status);
}
return;
}
if (pathname === "/api/table/sql" && method === "POST") {
const body = await readJsonBody(req);
const connectionId = getString(body, "connectionId");
try {
const adapter = await pool.getAdapter(connectionId);
const schema = getString(body, "schema");
const table = getString(body, "table");
sendJson(res, {
select: generateSelectSql(adapter.type, schema, table, getNumber(body, "limit", 100)),
count: generateCountSql(adapter.type, schema, table),
});
} catch (error) {
sendAdapterError(res, connectionId, error);
}
return;
}
// --- Query run -----------------------------------------------------------
if (pathname === "/api/query/run" && method === "POST") {
const body = await readJsonBody(req);
const connectionId = getString(body, "connectionId");
const sql = getString(body, "sql");
const limit = getNumber(body, "limit", 0);
const readOnly = body.readOnly === undefined ? serverReadOnlyDefault : Boolean(body.readOnly);
const confirmDangerous = Boolean(body.confirmDangerous);
if (!connectionId) {
sendJson(res, { ok: false, error: "Select a connection first." });
return;
}
const safety = analyzeSqlSafety(sql, { readOnly });
if (safety.blockedByReadOnly) {
sendJson(res, { ok: false, blocked: true, safety, error: `Read-only mode blocks: ${safety.matchedKeywords.join(", ")}` });
return;
}
if (safety.isDestructive && !confirmDangerous) {
sendJson(res, { ok: false, needsConfirmation: true, safety });
return;
}
const connection = await getResolvedConnection(connectionId).catch(() => undefined);
try {
const adapter = await pool.getAdapter(connectionId);
const effectiveSql = appendSafeLimit(adapter.type, sql, limit);
const result = await adapter.runQuery(effectiveSql, { maxRows: limit > 0 ? limit : undefined });
await appendQueryHistory({
connectionId,
connectionName: connection?.name,
connectionType: adapter.type,
sql,
durationMs: result.durationMs,
success: true,
rowCount: result.rowCount,
});
sendJson(res, { ok: true, result, safety, effectiveSql });
} catch (error) {
const built = buildAdapterError(connectionId, error);
await appendQueryHistory({
connectionId,
connectionName: connection?.name,
connectionType: connection?.type,
sql,
durationMs: 0,
success: false,
error: built.payload.error,
});
sendJson(res, { ok: false, ...built.payload }, built.status);
}
return;
}
// --- Saved queries -------------------------------------------------------
if (pathname === "/api/queries" && method === "GET") {
sendJson(res, { queries: await listSavedQueries() });
return;
}
if (pathname === "/api/queries" && method === "POST") {
const body = await readJsonBody(req);
const query = await saveQuery({
name: getString(body, "name"),
sql: getString(body, "sql"),
connectionId: getString(body, "connectionId") || undefined,
connectionType: (getString(body, "connectionType") || undefined) as TDatabaseType | undefined,
tags: Array.isArray(body.tags) ? (body.tags as string[]) : undefined,
});
sendJson(res, { query });
return;
}
if (pathname.startsWith("/api/queries/") && method === "PUT") {
const id = decodeURIComponent(pathname.slice("/api/queries/".length));
const body = await readJsonBody(req);
const name = getString(body, "name");
const sql = getString(body, "sql");
const query = sql
? await saveQuery({ id, name, sql, connectionId: getString(body, "connectionId") || undefined })
: await renameSavedQuery(id, name);
sendJson(res, { query });
return;
}
if (pathname.startsWith("/api/queries/") && method === "DELETE") {
const id = decodeURIComponent(pathname.slice("/api/queries/".length));
sendJson(res, { deleted: await deleteSavedQuery(id) });
return;
}
// --- Workspace + settings ------------------------------------------------
if (pathname === "/api/studio/workspace" && method === "GET") {
sendJson(res, { workspace: await readWorkspace() });
return;
}
if (pathname === "/api/studio/workspace" && method === "PUT") {
const body = await readJsonBody(req);
const workspace = await writeWorkspace(body as unknown as TStudioWorkspaceState);
sendJson(res, { workspace });
return;
}
if (pathname === "/api/studio/settings" && method === "GET") {
sendJson(res, { settings: await readStudioSettings() });
return;
}
if (pathname === "/api/studio/settings" && method === "PUT") {
const body = await readJsonBody(req);
const settings = await writeStudioSettings(body);
sendJson(res, { settings });
return;
}
// --- SQL helpers ---------------------------------------------------------
if (pathname === "/api/sql/format" && method === "POST") {
const body = await readJsonBody(req);
sendJson(res, { sql: formatSql(getString(body, "sql")) });
return;
}
if (pathname === "/api/sql/parse-statements" && method === "POST") {
const body = await readJsonBody(req);
sendJson(res, { statements: splitStatements(getString(body, "sql")) });
return;
}
if (pathname === "/api/sql/generate-table-query" && method === "POST") {
const body = await readJsonBody(req);
const connectionId = getString(body, "connectionId");
try {
const adapter = await pool.getAdapter(connectionId);
const sql = generateTableQuery({
type: adapter.type,
schema: getString(body, "schema"),
table: getString(body, "table"),
where: getString(body, "where") || undefined,
sort: Array.isArray(body.sort) ? (body.sort as TGridSortState[]) : undefined,
limit: getNumber(body, "limit", 100),
offset: getNumber(body, "offset", 0),
});
sendJson(res, { sql });
} catch (error) {
sendAdapterError(res, connectionId, error);
}
return;
}
if (pathname === "/api/table/generate-sql" && method === "POST") {
const body = await readJsonBody(req);
const connectionId = getString(body, "connectionId");
try {
const adapter = await pool.getAdapter(connectionId);
const schema = getString(body, "schema");
const table = getString(body, "table");
const [columns, primaryKey] = await Promise.all([
adapter.listColumns(schema, table).catch(() => []),
adapter.getPrimaryKey(schema, table).catch(() => ({ columns: [] })),
]);
sendJson(res, {
select: generateSelectSql(adapter.type, schema, table, getNumber(body, "limit", 100)),
count: generateCountSql(adapter.type, schema, table),
insert: generateInsertTemplate(adapter.type, schema, table, columns),
update: generateUpdateTemplate(adapter.type, schema, table, columns, primaryKey.columns),
});
} catch (error) {
sendAdapterError(res, connectionId, error);
}
return;
}
// --- History -------------------------------------------------------------
if (pathname === "/api/history" && method === "GET") {
sendJson(res, { history: await listQueryHistory(100) });
return;
}
if (pathname === "/api/history" && method === "DELETE") {
const { clearQueryHistory } = await import("./db-query-history");
await clearQueryHistory();
sendJson(res, { cleared: true });
return;
}
// --- Export --------------------------------------------------------------
if (pathname === "/api/export/csv" && method === "POST") {
const body = await readJsonBody(req);
const fields = Array.isArray(body.fields) ? (body.fields as string[]) : [];
const rows = Array.isArray(body.rows) ? (body.rows as Array>) : [];
sendText(res, toCsv(fields, rows), "text/csv; charset=utf-8", "result.csv");
return;
}
if (pathname === "/api/export/json" && method === "POST") {
const body = await readJsonBody(req);
const rows = Array.isArray(body.rows) ? body.rows : [];
sendText(res, JSON.stringify(rows, null, 2), "application/json; charset=utf-8", "result.json");
return;
}
if (pathname === "/api/export/data" && method === "POST") {
const body = await readJsonBody(req);
const source = getString(body, "source");
const format = getString(body, "format") === "json" ? "json" : "csv";
const schema = getString(body, "schema");
const table = getString(body, "objectName");
const selectedColumns = Array.isArray(body.selectedColumns) ? (body.selectedColumns as string[]) : undefined;
let rows: Array> = [];
let fields: string[] = [];
if (source === "selected-rows" && Array.isArray(body.selectedRows)) {
rows = body.selectedRows as Array>;
fields = selectedColumns ?? (rows[0] ? Object.keys(rows[0]) : []);
} else {
const adapter = await pool.getAdapter(getString(body, "connectionId"));
const sort = Array.isArray(body.sort) ? (body.sort as TGridSortState[]) : [];
const isPage = source === "current-page";
const result = await adapter.getTableData({
schema,
table,
limit: isPage ? getNumber(body, "limit", 100) : 100000,
offset: isPage ? getNumber(body, "offset", 0) : 0,
where: source === "whole-table" ? undefined : getString(body, "whereClause") || undefined,
orderBy: sort[0]?.column,
orderDirection: sort[0]?.direction === "desc" ? "desc" : "asc",
});
rows = result.rows;
fields = selectedColumns ?? result.fields;
}
if (selectedColumns) {
rows = rows.map((row) => {
const picked: Record = {};
for (const column of selectedColumns) picked[column] = row[column];
return picked;
});
fields = selectedColumns;
}
if (format === "json") {
sendText(res, JSON.stringify(rows, null, 2), "application/json; charset=utf-8", `${table || "result"}.json`);
} else {
sendText(res, toCsv(fields, rows), "text/csv; charset=utf-8", `${table || "result"}.csv`);
}
return;
}
// Static assets + SPA client-side routes (anything else that's a GET, not under /api).
if (method === "GET" && !pathname.startsWith("/api/") && !options.apiOnly) {
await serveStudioAsset(pathname, res);
return;
}
res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "Not found" }));
};
const server = http.createServer((req, res) => {
router(req, res).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
if (!res.headersSent) {
sendJson(res, { error: message }, 500);
} else {
res.end();
}
});
});
await new Promise((resolve, reject) => {
// Without this, a listen()-time failure (e.g. a TOCTOU race — findAvailablePort checked the
// port a moment ago, something else grabbed it since) emits 'error' on `server` with no
// listener attached, which is one of the few EventEmitter events Node treats as fatal by
// default: an uncaught exception that could take down the whole studio process instead of
// just failing this one startup attempt with a normal rejected promise the caller can catch.
server.once("error", (error: NodeJS.ErrnoException) => {
if (error.code === "EADDRINUSE") {
reject(new Error(`Port ${port} is already in use — something grabbed it after it was checked as free. Try again, or pass --port to pick a different one.`));
return;
}
reject(error);
});
server.listen(port, "127.0.0.1", resolve);
});
const url = `http://127.0.0.1:${port}`;
if (options.apiOnly) {
reportStudioStartupLine(options.onLog, `SimpleMDG DB Studio API: ${url}`, chalk.green);
reportStudioStartupLine(options.onLog, "Running in --api-only mode (no UI is served here).", chalk.gray);
reportStudioStartupLine(options.onLog, "In another terminal, run:", chalk.gray);
reportStudioStartupLine(options.onLog, " cd studio && npm run dev", chalk.cyan);
reportStudioStartupLine(options.onLog, `Vite will proxy /api/* to ${url}.`, chalk.gray);
} else {
reportStudioStartupLine(options.onLog, `SimpleMDG DB Studio: ${url}`, chalk.green);
}
if (serverReadOnlyDefault) {
reportStudioStartupLine(options.onLog, "Read-only mode is ON. Write/DDL statements are blocked.", chalk.yellow);
}
reportStudioStartupLine(options.onLog, "Server is bound to 127.0.0.1 only. Press Ctrl+C to stop.", chalk.gray);
if (!options.apiOnly && !process.env.SMDG_STUDIO_NO_OPEN) {
await openBrowser(url);
}
return {
url,
port,
close: async () => {
await pool.closeAll();
// See ai-studio-server.ts's close() for why closeAllConnections() is needed here:
// the browser tab opened by openBrowser() holds a keep-alive socket that would
// otherwise keep server.close()'s callback from ever firing.
await new Promise((resolve) => {
server.close(() => resolve());
server.closeAllConnections();
});
},
};
}