feat(safety): add production environment safeguards

This commit is contained in:
wuxiemian authored and GitHub committed 2026-07-12 10:02:27 +08:00
1 parent be66d6f46b
commit 1be66fb115
80 files changed
+4220 -234

No files matched your search

@@ -10,6 +10,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import type { ConnectionConfig } from "@/types/database";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import {
DAMENG_JOB_ENVIRONMENT_SQL,
damengClearJobHistoriesSql,
@@ -169,10 +170,18 @@ async function applyPendingSql() {
applying.value = true;
try {
await ensureConnection();
await api.executeMulti(props.connection.id, executionDatabase.value, pendingSql.value, undefined, undefined, {
maxRows: 1000,
useTransaction: pendingUseTransaction.value,
const result = await executeWithProductionSqlGuard({
connection: props.connection,
database: executionDatabase.value,
sql: pendingSql.value,
source: t("production.sourceAdmin"),
execute: () =>
api.executeMulti(props.connection.id, executionDatabase.value, pendingSql.value, undefined, undefined, {
maxRows: 1000,
useTransaction: pendingUseTransaction.value,
}),
});
if (!result) return;
toast(t("damengJobAdmin.applySuccess"), 2500);
previewDialogOpen.value = false;
await (pendingAfterApply.value?.() ?? Promise.resolve());
@@ -13,6 +13,7 @@ import { useToast } from "@/composables/useToast";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import type { ConnectionConfig } from "@/types/database";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { grantsFromQueryResult, getDatabaseUserAdminProvider, supportsDatabaseUserAdmin, type DatabaseUserIdentity, type PrivilegeScope } from "@/lib/database/databaseUserAdmin";
const props = defineProps<{
@@ -165,7 +166,14 @@ async function applyPendingSql() {
if (!pendingSql.value.trim()) return;
applying.value = true;
try {
await api.executeMulti(props.connection.id, "", pendingSql.value, undefined, undefined, { maxRows: 1000 });
const result = await executeWithProductionSqlGuard({
connection: props.connection,
database: "",
sql: pendingSql.value,
source: t("production.sourceAdmin"),
execute: () => api.executeMulti(props.connection.id, "", pendingSql.value, undefined, undefined, { maxRows: 1000 }),
});
if (!result) return;
toast(t("userAdmin.applySuccess"), 2500);
sqlDialogOpen.value = false;
await (pendingAfterApply.value?.() ?? Promise.resolve());
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { ShieldAlert } from "@lucide/vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
withDefaults(
defineProps<{
compact?: boolean;
}>(),
{ compact: false },
);
</script>
<template>
<span class="inline-flex h-5 shrink-0 items-center gap-1 rounded-[4px] border border-red-500/40 bg-red-500/10 px-1.5 font-mono text-[10px] font-semibold leading-none text-red-700 dark:text-red-300" :class="compact ? 'px-1' : ''" :title="t('production.title')">
<ShieldAlert class="h-3 w-3" aria-hidden="true" />
<span>PROD</span>
</span>
</template>
@@ -43,10 +43,10 @@ import { normalizeKafkaBootstrapServers } from "@/lib/connection/kafkaBootstrapS
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth";
import { driverInstallProgressPercent, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
import { isSqlServerLegacyCompatibilityMode, requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityMode, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pencil, Pipette, Plus, Search, ShieldCheck, Square, Trash2 } from "@lucide/vue";
import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pencil, Pipette, Plus, RefreshCw, Search, ShieldAlert, ShieldCheck, Square, Trash2 } from "@lucide/vue";
import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleDatabaseSelectionIsStale } from "@/lib/connection/connectionVisibleDatabases";
import { canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, isSystemDatabaseName, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases";
import { isSchemaAware } from "@/lib/database/databaseFeatureSupport";
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
import VisibleSchemasDialog from "@/components/sidebar/VisibleSchemasDialog.vue";
import { oceanbaseModeConnectionPatch, oceanbaseSubModeFromConfig } from "@/lib/database/oceanbaseConnectionMode";
import { translateBackendError } from "@/i18n/backend-errors";
@@ -57,6 +57,7 @@ type DbCategory = { key: string; title: string; options: DbOption[] };
type DialogStep = "select" | "config";
type DbPickerView = "icon" | "list";
export type ConfigTab = "connection" | "advanced" | "tls" | "transport";
type ProductionScope = "connection" | "databases";
type MqTokenSigningMode = "none" | "hs256" | "rs256";
type NacosAuthKind = NacosAuthConfig["kind"];
type DremioConnectionMode = "arrow-flight-sql" | "legacy";
@@ -136,6 +137,13 @@ const visibleDatabaseSelection = ref<Set<string>>(new Set());
const visibleDatabaseSearchText = ref("");
const visibleDatabaseError = ref("");
const visibleDatabaseShowSystem = ref(false);
const showProductionDatabasesDialog = ref(false);
const isLoadingProductionDatabases = ref(false);
const productionDatabaseNames = ref<string[]>([]);
const productionDatabaseSelection = ref<Set<string>>(new Set());
const productionDatabaseSearchText = ref("");
const productionDatabaseError = ref("");
const productionProtectionEnabled = ref(false);
const showVisibleSchemasDialog = ref(false);
const isLoadingVisibleSchemas = ref(false);
const visibleSchemaNames = ref<string[]>([]);
@@ -189,6 +197,8 @@ const defaultForm = (): ConnectionForm => ({
informix_server: "",
external_config: undefined,
read_only: false,
is_production: false,
production_databases: [],
visible_databases: undefined,
});
@@ -1430,8 +1440,11 @@ watch(
informix_server: config.informix_server || "",
external_config: config.external_config,
read_only: config.read_only || false,
is_production: config.is_production || false,
production_databases: config.production_databases || [],
visible_databases: config.visible_databases,
};
productionProtectionEnabled.value = !!config.is_production || (config.production_databases?.length ?? 0) > 0;
connectionUrlInput.value = config.db_type === "h2" && config.connection_string ? config.connection_string : "";
appliedConnectionUrlInput.value = connectionUrlInput.value.trim();
if (config.db_type === "mq") {
@@ -1471,6 +1484,7 @@ watch(
} else {
editingId.value = null;
form.value = defaultForm();
productionProtectionEnabled.value = false;
selectedTransportLayerId.value = null;
selectedType.value = "mysql";
customDriverName.value = "";
@@ -1893,6 +1907,37 @@ const visibleDatabaseHasSystemDatabases = computed(() => {
const connection = connectionConfigSnapshotForVisibleDatabases();
return visibleDatabaseNames.value.some((database) => isSystemDatabaseName(connection.db_type, database));
});
const filteredProductionDatabaseNames = computed(() => {
const query = productionDatabaseSearchText.value.trim().toLowerCase();
if (!query) return productionDatabaseNames.value;
return productionDatabaseNames.value.filter((name) => name.toLowerCase().includes(query));
});
const productionDatabaseSelectedCount = computed(() => productionDatabaseSelection.value.size);
const productionDatabaseCanSave = computed(() => productionDatabaseNames.value.length > 0 && productionDatabaseSelection.value.size > 0);
const productionDatabaseSummary = computed(() => {
const selected = form.value.production_databases?.length || 0;
if (!selected) return t("production.noDatabasesSelected");
if (!productionDatabaseNames.value.length) return t("production.databasesConfiguredCount", { count: selected });
return t("production.databasesSelectedCount", { selected, total: productionDatabaseNames.value.length });
});
const productionScope = computed<ProductionScope>({
get: () => (isSingleDatabase(form.value.db_type) || form.value.is_production ? "connection" : "databases"),
set: (scope) => {
form.value.is_production = isSingleDatabase(form.value.db_type) || scope === "connection";
},
});
const canSelectProductionDatabases = computed(() => !isSingleDatabase(form.value.db_type));
function setProductionProtectionEnabled(enabled: boolean) {
productionProtectionEnabled.value = enabled;
if (!enabled) {
form.value.is_production = false;
form.value.production_databases = [];
} else if (!form.value.is_production && !form.value.production_databases?.length) {
// Enabling protection starts with the broadest scope until the user chooses a narrower one.
form.value.is_production = true;
}
}
const canChooseVisibleSchemas = computed(() => isSchemaAware(form.value.db_type));
const visibleSchemasDatabaseKey = computed(() => form.value.database || "");
const hasVisibleSchemaFilter = computed(() => {
@@ -2254,6 +2299,14 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
}
if (!config.one_time) config.one_time = undefined;
if (!config.read_only) config.read_only = undefined;
if (isSingleDatabase(config.db_type) && config.production_databases?.length) {
// Single-database drivers expose schemas or internal names, not independently selectable databases.
config.is_production = true;
config.production_databases = [];
}
if (!config.is_production) config.is_production = undefined;
config.production_databases = [...new Set((config.production_databases || []).map((database) => database.trim()).filter(Boolean))];
if (!config.production_databases.length) config.production_databases = undefined;
if (config.db_type === "mq") {
const mqConfig = buildMqAdminConfig();
config.external_config = mqConfig;
@@ -2685,6 +2738,16 @@ function resetVisibleDatabaseDraftState() {
visibleDatabaseShowSystem.value = false;
}
function resetProductionDatabaseDraftState() {
showProductionDatabasesDialog.value = false;
isLoadingProductionDatabases.value = false;
productionDatabaseNames.value = [];
productionDatabaseSelection.value = new Set();
productionDatabaseSearchText.value = "";
productionDatabaseError.value = "";
productionProtectionEnabled.value = false;
}
/** Silently load database names so the summary count shows a real total. */
async function preloadVisibleDatabaseNames() {
if (!ensureConnectionHostResolvedFromUrl()) return;
@@ -2755,6 +2818,88 @@ async function loadVisibleDatabaseNames(connectionId: string, config: Connection
return (await api.listDatabases(connectionId)).map((database) => database.name);
}
function normalizeProductionDatabaseSelection(selectedNames: Iterable<string>, databaseNames: string[]): string[] {
const available = new Map(databaseNames.map((name) => [name.toLowerCase(), name]));
const selected = new Set<string>();
for (const name of selectedNames) {
const canonicalName = available.get(name.toLowerCase());
if (canonicalName) selected.add(canonicalName);
}
return [...selected];
}
function initialProductionDatabaseSelection(databaseNames: string[]): string[] {
const configured = form.value.production_databases || [];
// A new database-level safeguard starts broad; users can explicitly narrow it in the picker.
return configured.length ? normalizeProductionDatabaseSelection(configured, databaseNames) : databaseNames;
}
async function loadProductionDatabaseNames(connectionId: string, config: ConnectionConfig): Promise<string[]> {
if (config.db_type === "redis") {
return (await api.redisListDatabases(connectionId)).map((database) => String(database.db));
}
if (config.db_type === "mongodb") {
return api.mongoListDatabases(connectionId);
}
return (await api.listDatabases(connectionId)).map((database) => database.name);
}
async function openProductionDatabasesPicker() {
if (!ensureConnectionHostResolvedFromUrl() || !productionProtectionEnabled.value || form.value.is_production || isLoadingProductionDatabases.value) return;
showProductionDatabasesDialog.value = true;
await reloadProductionDatabases();
}
async function reloadProductionDatabases() {
if (isLoadingProductionDatabases.value) return;
isLoadingProductionDatabases.value = true;
productionDatabaseError.value = "";
productionDatabaseSearchText.value = "";
const draftId = `__production_database_draft_${uuid()}`;
try {
const draftConfig = {
...connectionConfigForSubmit(draftId),
id: draftId,
one_time: true,
};
await api.connectDb(draftConfig);
productionDatabaseNames.value = await loadProductionDatabaseNames(draftId, draftConfig);
productionDatabaseSelection.value = new Set(initialProductionDatabaseSelection(productionDatabaseNames.value));
} catch (e: any) {
productionDatabaseNames.value = [];
productionDatabaseSelection.value = new Set();
productionDatabaseError.value = mongodbAuthFailureHint(errorMessage(e));
} finally {
await api.disconnectDb(draftId).catch(() => undefined);
isLoadingProductionDatabases.value = false;
}
}
function toggleProductionDatabase(database: string) {
const next = new Set(productionDatabaseSelection.value);
if (next.has(database)) next.delete(database);
else next.add(database);
productionDatabaseSelection.value = next;
}
function selectAllProductionDatabases() {
productionDatabaseSelection.value = new Set(productionDatabaseNames.value);
}
function clearProductionDatabaseSelection() {
productionDatabaseSelection.value = new Set();
}
function saveProductionDatabaseSelection() {
if (!productionDatabaseCanSave.value) return;
// A database selection is always narrower than a connection-wide marker.
productionProtectionEnabled.value = true;
form.value.is_production = false;
form.value.production_databases = normalizeProductionDatabaseSelection(productionDatabaseSelection.value, productionDatabaseNames.value);
showProductionDatabasesDialog.value = false;
}
function toggleVisibleDatabase(database: string) {
const next = new Set(visibleDatabaseSelection.value);
if (next.has(database)) next.delete(database);
@@ -2905,6 +3050,7 @@ function resetForm() {
dbSearchQuery.value = "";
configTab.value = "connection";
resetVisibleDatabaseDraftState();
resetProductionDatabaseDraftState();
resetVisibleSchemasState();
resetTestState();
}
@@ -5084,6 +5230,39 @@ function openExternalUrl(url: string) {
<span class="text-xs text-muted-foreground">{{ t("connection.readOnlyHint") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-start gap-4 rounded-[6px] border border-red-500/25 bg-red-500/[0.035] px-3 py-2.5">
<Label :class="[connectionLabelSmallClass, 'pt-0.5 text-red-700 dark:text-red-300']">
<span class="inline-flex items-center justify-end gap-1"><ShieldAlert class="h-3.5 w-3.5" />PROD</span>
</Label>
<div class="col-span-3 grid gap-2">
<div class="flex items-center justify-between gap-3">
<Label class="text-sm font-medium">{{ t("production.enable") }}</Label>
<Switch :model-value="productionProtectionEnabled" @update:model-value="setProductionProtectionEnabled" />
</div>
<p v-if="!productionProtectionEnabled" class="text-xs leading-5 text-muted-foreground">{{ t("production.disabledDescription") }}</p>
<template v-else>
<Label class="text-xs font-medium">{{ t("production.scope") }}</Label>
<Tabs v-model="productionScope" class="w-full">
<TabsList class="grid h-8 w-full grid-cols-2">
<TabsTrigger value="connection" class="text-xs">{{ t("production.allDatabases") }}</TabsTrigger>
<TabsTrigger value="databases" class="text-xs" :disabled="!canSelectProductionDatabases" :title="canSelectProductionDatabases ? undefined : t('production.singleDatabaseScopeHint')">{{ t("production.selectedDatabases") }}</TabsTrigger>
</TabsList>
</Tabs>
<p class="text-xs leading-5 text-muted-foreground">{{ productionScope === "connection" ? t("production.connectionDescription") : t("production.databaseDescription") }}</p>
<div v-if="productionScope === 'databases'" class="grid gap-1.5">
<div class="flex items-center justify-between gap-3">
<Label class="text-xs font-medium">{{ t("production.databases") }}</Label>
<span class="text-xs text-muted-foreground">{{ productionDatabaseSummary }}</span>
</div>
<Button type="button" variant="outline" size="sm" class="justify-start" :disabled="isTesting || isSaving || isLoadingProductionDatabases || !hasRequiredConnectionTarget" @click="openProductionDatabasesPicker">
<Loader2 v-if="isLoadingProductionDatabases" class="mr-1.5 h-4 w-4 animate-spin" />
<ListFilter v-else class="mr-1.5 h-4 w-4" />
{{ t("production.selectDatabases") }}
</Button>
</div>
</template>
</div>
</div>
<div v-show="form.db_type === 'sqlserver'" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sqlServerLegacyCompatibilityMode") }}</Label>
<div class="col-span-3 flex flex-col gap-1">
@@ -5505,6 +5684,74 @@ function openExternalUrl(url: string) {
</DialogContent>
</Dialog>
<Dialog v-model:open="showProductionDatabasesDialog">
<DialogContent class="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{{ t("production.databasePickerTitle") }}</DialogTitle>
<p class="text-sm text-muted-foreground">
{{ t("production.databasePickerDescription", { connection: form.name || selectedProfile().label }) }}
</p>
</DialogHeader>
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
<Input v-model="productionDatabaseSearchText" :placeholder="t('production.databaseSearchPlaceholder')" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoadingProductionDatabases || !!productionDatabaseError" />
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t("production.databasesSelectedCount", { selected: productionDatabaseSelectedCount, total: productionDatabaseNames.length }) }}</span>
<div class="flex items-center gap-2">
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingProductionDatabases || !!productionDatabaseError" @click="selectAllProductionDatabases">
{{ t("visibleDatabases.selectAll") }}
</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingProductionDatabases || !!productionDatabaseError" @click="clearProductionDatabaseSelection">
{{ t("visibleDatabases.clear") }}
</button>
</div>
</div>
<p v-if="!isLoadingProductionDatabases && !productionDatabaseError && !productionDatabaseCanSave" class="text-xs text-destructive">
{{ t("production.databaseSelectionRequired") }}
</p>
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
<div v-if="isLoadingProductionDatabases" class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="productionDatabaseError" class="flex h-full flex-col items-start justify-center gap-3 p-3 text-sm text-destructive">
<p>{{ t("production.databaseLoadFailed", { message: productionDatabaseError }) }}</p>
<Button type="button" variant="outline" size="sm" @click="reloadProductionDatabases">
<RefreshCw class="mr-1.5 h-3.5 w-3.5" />
{{ t("production.retry") }}
</Button>
</div>
<div v-else-if="!filteredProductionDatabaseNames.length" class="p-3 text-sm text-muted-foreground">
{{ productionDatabaseNames.length ? t("grid.noSearchResults") : t("production.noDatabasesAvailable") }}
</div>
<template v-else>
<button
v-for="database in filteredProductionDatabaseNames"
:key="database"
type="button"
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="toggleProductionDatabase(database)"
>
<CheckSquare v-if="productionDatabaseSelection.has(database)" class="h-4 w-4 shrink-0 text-primary" />
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="truncate">{{ database }}</span>
</button>
</template>
</div>
<DialogFooter>
<Button variant="outline" @click="showProductionDatabasesDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="isLoadingProductionDatabases || !!productionDatabaseError || !productionDatabaseCanSave" @click="saveProductionDatabaseSelection">
{{ t("visibleDatabases.save") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<VisibleSchemasDialog
v-model:open="showVisibleSchemasDialog"
draft-mode
@@ -15,6 +15,7 @@ import { copyToClipboard } from "@/lib/common/clipboard";
import type { DataCompareCellValue, DataCompareModifiedRow, DataCompareResult, DataCompareRow, DataCompareSyncPlan, DataCompareSyncPlanTableOptions } from "@/lib/dataGrid/dataCompare";
import type { ColumnInfo, DatabaseType } from "@/types/database";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { ArrowLeftRight, CheckSquare, ChevronDown, ChevronRight, Copy, GitCompareArrows, Loader2, Play, Square } from "@lucide/vue";
@@ -713,30 +714,40 @@ async function copySql() {
async function executeSql() {
if (!syncPlan.value.syncSql.trim() || syncPlan.value.syncStatements.length === 0 || executing.value) return;
executing.value = true;
syncErrors.value = [];
executeTotal.value = syncPlan.value.syncStatements.length;
executedCount.value = 0;
const targetConnection = store.getConfig(targetConnectionId.value);
try {
await store.ensureConnected(targetConnectionId.value);
const statements = syncPlan.value.syncStatements;
for (let index = 0; index < statements.length; index += SYNC_EXECUTE_BATCH_SIZE) {
const batch = statements.slice(index, index + SYNC_EXECUTE_BATCH_SIZE);
try {
await api.executeBatch(targetConnectionId.value, targetDatabase.value, batch, targetSchema.value);
executedCount.value += batch.length;
} catch (e: any) {
for (const stmt of batch) {
const failed = await executeWithProductionSqlGuard({
connection: targetConnection,
database: targetDatabase.value,
sql: syncPlan.value.syncSql,
source: t("production.sourceDataCompare"),
execute: async () => {
executing.value = true;
syncErrors.value = [];
executeTotal.value = syncPlan.value.syncStatements.length;
executedCount.value = 0;
await store.ensureConnected(targetConnectionId.value);
const statements = syncPlan.value.syncStatements;
for (let index = 0; index < statements.length; index += SYNC_EXECUTE_BATCH_SIZE) {
const batch = statements.slice(index, index + SYNC_EXECUTE_BATCH_SIZE);
try {
await api.executeBatch(targetConnectionId.value, targetDatabase.value, [stmt], targetSchema.value);
} catch (singleError: any) {
syncErrors.value.push({ sql: stmt, error: singleError?.message || String(singleError) });
await api.executeBatch(targetConnectionId.value, targetDatabase.value, batch, targetSchema.value);
executedCount.value += batch.length;
} catch (e: any) {
for (const stmt of batch) {
try {
await api.executeBatch(targetConnectionId.value, targetDatabase.value, [stmt], targetSchema.value);
} catch (singleError: any) {
syncErrors.value.push({ sql: stmt, error: singleError?.message || String(singleError) });
}
executedCount.value++;
}
}
executedCount.value++;
}
}
}
const failed = syncErrors.value.length;
return syncErrors.value.length;
},
});
if (failed === undefined) return;
if (failed === 0) {
toast(t("dataCompare.syncSuccess"), 2000);
} else {
@@ -7,6 +7,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import { GitCompareArrows, ArrowLeft, Play, Loader2, Maximize2, Minimize2, AlertTriangle, CircleCheck } from "@lucide/vue";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { useSchemaDiffConfig } from "@/composables/useSchemaDiffConfig";
import SchemaDiffConfigStep from "@/components/diff/SchemaDiffConfigStep.vue";
import SchemaDiffObjectTree from "@/components/diff/SchemaDiffObjectTree.vue";
@@ -496,7 +497,14 @@ async function handleExecuteScript() {
executing.value = true;
try {
await api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value);
const result = await executeWithProductionSqlGuard({
connection: store.getConfig(targetConnectionId.value),
database: targetDatabase.value,
sql: deploySql.value,
source: t("production.sourceSchemaDiff"),
execute: () => api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value),
});
if (!result) return;
toast(t("diff.executeSuccess"), 3000);
} catch (e: any) {
toast(e?.message || String(e), 5000);
@@ -621,7 +629,14 @@ async function onConfirmDeploy() {
showConfirmDialog.value = false;
executing.value = true;
try {
const result = await api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value);
const result = await executeWithProductionSqlGuard({
connection: store.getConfig(targetConnectionId.value),
database: targetDatabase.value,
sql: deploySql.value,
source: t("production.sourceSchemaDiff"),
execute: () => api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value),
});
if (!result) return;
deployResult.value = {
success: true,
message: t("diff.deploySuccess"),
@@ -57,6 +57,9 @@ import { buildAiContext, runAgentStream, isVectorDbType, isValidActionForMode, d
import { formatAiModelOption } from "@/lib/ai/aiModelPresentation";
import type { AgentEvent } from "@/lib/backend/tauri";
import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan";
import { extractFirstSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
import { buildAiAgentStepItems, toolCallStepKey, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation";
import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender";
@@ -496,6 +499,8 @@ const proposalConfirmMessage = computed<ChatMessage | null>(() => {
let allowWriteSqlForNextRun = false;
const productionContext = computed(() => productionContextForDatabase(props.connection, props.tab?.database));
function proposalContainsWriteSql(content: string) {
return /\b(insert|update|delete|replace|merge|create|alter|drop|truncate|rename|grant|revoke)\b/i.test(content);
}
@@ -505,6 +510,12 @@ function sendProposalReply(positive: boolean) {
if (isGenerating.value) return;
const target = proposalConfirmMessage.value;
if (!target) return;
if (positive && productionContext.value.active && proposalContainsWriteSql(target.content)) {
const sql = extractFirstSqlCodeBlock(target.content);
if (sql) emit("replaceSql", sql);
toast(t("production.aiReviewRequired"), 5000);
return;
}
const isZh = containsChinese(target.content || "");
const replyZh = positive ? "请执行上面你刚提议的操作,不要再反问确认。" : "不用执行上面提到的操作,继续当前对话。";
const replyEn = positive ? "Execute the action you just proposed above; do not ask for confirmation again." : "Do not execute the action mentioned above; continue the current conversation.";
@@ -1396,7 +1407,8 @@ async function send() {
const requestedAction = activeAction.value;
const requestedMode = assistantMode.value;
const allowWriteSql = requestedMode === "agent" && allowWriteSqlForNextRun;
// Agent confirmation cannot grant autonomous writes while the active database is production.
const allowWriteSql = requestedMode === "agent" && allowWriteSqlForNextRun && !productionContext.value.active;
allowWriteSqlForNextRun = false;
isGenerating.value = true;
messages.value.push({ role: "assistant", content: "" });
@@ -1483,6 +1495,7 @@ async function send() {
instruction: modelInstruction,
assistantContent: msg?.content || "",
connection: props.connection,
database: props.tab?.database,
});
if (msg && requestedMode === "agent") msg.agentSteps = buildAiAgentStepItems(agentPlan);
if (agentPlan.handoffSql) emit("requestAutoExecuteSql", agentPlan.handoffSql);
@@ -1739,6 +1752,7 @@ async function openExternalUrl(url: string) {
<span class="flex flex-1 self-stretch items-center truncate text-xs font-medium" data-tauri-drag-region>
{{ chatTitle }}
</span>
<ProductionContextBadge v-if="productionContext.active" compact />
<Button variant="ghost" size="icon" class="h-6 w-6" @click="startNewChat" :title="t('ai.newChat')">
<MessageSquarePlus class="h-3.5 w-3.5" />
</Button>
@@ -9,6 +9,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { useHistoryStore } from "@/stores/historyStore";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import { resolveHistoryActivityKind } from "@/lib/history/historyActivityKind";
import { canRollbackHistoryEntry } from "@/lib/history/historyAiAnalysis";
@@ -16,12 +17,14 @@ import { hasHistoryDateRange, historyDateRangeIsValid, historyEntryMatchesDateRa
import { HISTORY_ROW_HEIGHT, HISTORY_SCROLL_BUFFER, shouldVirtualizeHistory } from "@/lib/history/historyVirtualList";
import type { HistoryEntry } from "@/lib/backend/api";
import { copyToClipboard } from "@/lib/common/clipboard";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import * as api from "@/lib/backend/api";
const { t } = useI18n();
const { toast } = useToast();
const { highlight } = useSqlHighlighter();
const store = useHistoryStore();
const connectionStore = useConnectionStore();
const emit = defineEmits<{
restore: [sql: string, entry: HistoryEntry];
@@ -226,7 +229,14 @@ async function rollback(entry: HistoryEntry) {
isRollingBack.value = true;
const start = Date.now();
try {
const result = await api.executeScript(connectionId, entry.database, rollbackSql);
const result = await executeWithProductionSqlGuard({
connection: connectionStore.getConfig(connectionId),
database: entry.database,
sql: rollbackSql,
source: t("production.sourceQueryHistory"),
execute: () => api.executeScript(connectionId, entry.database, rollbackSql),
});
if (!result) return;
await store.add({
connection_id: connectionId,
connection_name: entry.connection_name,
@@ -8,6 +8,7 @@ import { displayGeneratedValue, findGeneratorKey, formatGeneratedValue, generate
import { quoteTableIdentifier } from "@/lib/table/tableSelectSql";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import GeneratorParamsPanel from "./params/GeneratorParamsPanel.vue";
import type { ColumnInfo, TableInfo } from "@/types/database";
@@ -450,43 +451,57 @@ async function startInsert() {
const cid = props.prefillConnectionId;
const db = props.prefillDatabase;
if (!cid || !db) return;
executing.value = true;
const perTable: TableResult[] = [];
for (const r of generatedResults.value) {
const stmts = sqlStatementsForTable(r);
const rowCount = r.rows.length;
let ok = 0;
let lastError = "";
for (let si = 0; si < stmts.length; si++) {
try {
if (generateOptions.useTransaction) {
await api.executeInTransaction(cid, db, [stmts[si]], props.prefillSchema);
} else {
await api.executeQuery(cid, db, stmts[si], props.prefillSchema);
const sql = allSqlStatements().join("\n");
if (!sql.trim()) return;
try {
await executeWithProductionSqlGuard({
connection: store.getConfig(cid),
database: db,
sql,
source: t("production.sourceDataGenerate"),
execute: async () => {
executing.value = true;
const perTable: TableResult[] = [];
for (const r of generatedResults.value) {
const stmts = sqlStatementsForTable(r);
const rowCount = r.rows.length;
let ok = 0;
let lastError = "";
for (let si = 0; si < stmts.length; si++) {
try {
if (generateOptions.useTransaction) {
await api.executeInTransaction(cid, db, [stmts[si]], props.prefillSchema);
} else {
await api.executeQuery(cid, db, stmts[si], props.prefillSchema);
}
if (generateOptions.extendedInsert) {
ok = rowCount;
} else if (!(generateOptions.truncate && si === 0)) {
ok++;
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[startInsert] SQL error:", msg);
if (!lastError) lastError = msg;
if (generateOptions.extendedInsert) {
ok = 0;
}
if (!generateOptions.continueOnError) break;
}
}
perTable.push({ table: r.tableName, total: rowCount, ok, err: rowCount - ok, error: lastError || undefined });
if (ok > 0) {
store.invalidateMetadataCache(cid, db, props.prefillSchema || undefined, r.tableName);
}
}
if (generateOptions.extendedInsert) {
ok = rowCount;
} else if (!(generateOptions.truncate && si === 0)) {
ok++;
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[startInsert] SQL error:", msg);
if (!lastError) lastError = msg;
if (generateOptions.extendedInsert) {
ok = 0;
}
if (!generateOptions.continueOnError) break;
}
}
perTable.push({ table: r.tableName, total: rowCount, ok, err: rowCount - ok, error: lastError || undefined });
if (ok > 0) {
store.invalidateMetadataCache(cid, db, props.prefillSchema || undefined, r.tableName);
}
executeResults.value = perTable;
currentStep.value = "result";
return true;
},
});
} finally {
executing.value = false;
}
executeResults.value = perTable;
currentStep.value = "result";
executing.value = false;
}
const orderDialogOpen = ref(false);
@@ -18,6 +18,7 @@ const DatabaseSearchDialog = defineAsyncComponent(() => import("@/components/sea
const DatabaseExportDialog = defineAsyncComponent(() => import("@/components/export/DatabaseExportDialog.vue"));
const DataGenerateDialog = defineAsyncComponent(() => import("@/components/generate/DataGenerateDialog.vue"));
import { useConnectionStore } from "@/stores/connectionStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { useDialogSources } from "@/composables/useDialogSources";
import type { ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
import type { SqlParameterDescriptor, SqlParameterSyntax } from "@/lib/sql/sqlParameters";
@@ -82,7 +83,17 @@ const emit = defineEmits<{
const { t } = useI18n();
const connectionStore = useConnectionStore();
const productionSafetyStore = useProductionSafetyStore();
const dialogs = useDialogSources();
const productionConfirmationDetails = computed(() => {
const request = productionSafetyStore.pending;
if (!request) return "";
return t("production.confirmDetails", {
connection: request.connectionName || "-",
database: request.productionDatabases?.join(", ") || request.database || "-",
source: request.source || "-",
});
});
const editConfig = computed(() => {
const id = connectionStore.editingConnectionId;
@@ -136,6 +147,18 @@ watch(
@update:suppress-future-prompts="emit('update:suppressDangerConfirm', $event)"
@confirm="emit('dangerConfirm')"
/>
<DangerConfirmDialog
v-if="productionSafetyStore.pending"
:open="true"
:title="t('production.confirmTitle')"
:message="t('production.confirmMessage')"
:details-text="productionConfirmationDetails"
:sql="productionSafetyStore.pending.sql"
:confirm-label="t('production.confirmAction')"
:close-on-confirm="false"
@update:open="(open) => !open && productionSafetyStore.cancel()"
@confirm="productionSafetyStore.confirm()"
/>
<SqlParameterDialog
v-if="showSqlParameterDialog"
:open="showSqlParameterDialog"
@@ -5,7 +5,7 @@ import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { canReloadUnavailableDataTab } from "@/lib/table/tableDataRefresh";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { Check, Columns3, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus } from "@lucide/vue";
import { Check, Columns3, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert } from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
@@ -80,6 +80,7 @@ import { formatElapsedSeconds } from "@/lib/common/elapsedTime";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import type { QueryTab, ConnectionConfig, TableInfoTab, TreeNode, VectorCollectionMeta, ObjectBrowserViewport } from "@/types/database";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
type DataGridHandle = {
onToolbarRefresh: () => Promise<void> | void;
@@ -156,7 +157,7 @@ const emit = defineEmits<{
openConnectionSettings: [connectionId: string, initialTab: "advanced"];
}>();
const { t } = useI18n();
const { t, locale } = useI18n();
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
@@ -206,6 +207,13 @@ const activeTableMeta = computed(() => props.activeTab.tableMeta);
const activeDataTabTableMeta = computed(() => tableMetaForDataTab(props.activeTab));
const activeEffectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.activeConnection));
const activeDataTabExecutionDatabase = computed(() => dataTabExecutionDatabase(props.activeConnection, props.activeTab.database, activeDataTabTableMeta.value?.catalog));
const activeProductionContext = computed(() => productionContextForDatabase(props.activeConnection, props.activeTab.database));
const productionWatermarkText = computed(() => (locale.value.startsWith("zh") ? "生产环境" : "PROD"));
const productionSessionDetail = computed(() => {
if (!activeProductionContext.value.active) return "";
if (activeProductionContext.value.reason === "connection") return t("production.connection");
return activeProductionContext.value.databases.join(", ") || t("production.databases");
});
function findNodeInTree(nodes: TreeNode[], id: string): TreeNode | undefined {
for (const node of nodes) {
@@ -732,15 +740,23 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
</script>
<template>
<div class="flex flex-col flex-1 min-h-0">
<div class="production-session-shell flex flex-col flex-1 min-h-0" :class="{ 'production-session-shell--active': activeProductionContext.active }">
<div v-if="activeProductionContext.active" class="production-session-strip flex h-7 shrink-0 items-center gap-2 border-b border-red-500/35 bg-red-500/10 px-3 text-xs font-semibold text-red-800 shadow-[inset_0_1px_0_rgb(239_68_68_/_0.28)] dark:text-red-200">
<ShieldAlert class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span class="font-mono uppercase tracking-normal">{{ t("production.title") }}</span>
<span v-if="productionSessionDetail" class="min-w-0 truncate rounded-[4px] border border-red-500/25 bg-background/65 px-1.5 py-0.5 font-medium text-red-700 dark:text-red-200">{{ productionSessionDetail }}</span>
</div>
<!-- Query mode: editor + results -->
<template v-if="activeTab.mode === 'query'">
<Splitpanes horizontal class="query-output-splitpanes flex-1 min-h-0 overflow-hidden" @resized="onResultsResized">
<Pane class="min-h-0" :size="editorPaneSize" :min-size="resultsPaneOpen ? 15 : 100">
<div class="h-full flex flex-col relative">
<div v-if="activeProductionContext.active" class="production-watermark pointer-events-none absolute inset-0 z-10 grid select-none" aria-hidden="true">
<span v-for="index in 4" :key="index" class="production-watermark__label whitespace-nowrap font-mono text-6xl font-extrabold text-red-700/[0.24] dark:text-red-200/[0.2]">{{ productionWatermarkText }}</span>
</div>
<QueryEditor
ref="queryEditorRef"
class="flex-1"
class="relative z-0 flex-1"
:model-value="activeTab.sql"
:connection-id="activeTab.connectionId"
:database="activeTab.database"
@@ -1554,11 +1570,41 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
isolation: isolate;
}
.production-session-shell--active {
box-shadow: inset 3px 0 0 color-mix(in oklch, var(--destructive) 78%, transparent);
}
.production-session-strip {
background-image: linear-gradient(90deg, color-mix(in oklch, var(--destructive) 14%, transparent), color-mix(in oklch, var(--destructive) 7%, transparent));
}
.query-output-splitpanes :deep(> .splitpanes__splitter) {
z-index: 1;
flex: 0 0 3px;
}
.production-watermark {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 3rem;
overflow: hidden;
padding: 3rem 2.5rem;
}
.production-watermark__label {
align-self: center;
justify-self: center;
transform: rotate(-22deg);
}
@media (max-width: 700px) {
.production-watermark {
grid-template-columns: 1fr;
gap: 1.5rem;
padding-inline: 1rem;
}
}
.result-tab-scroll::-webkit-scrollbar {
display: none;
}
@@ -7,6 +7,7 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import TruncatedTextTooltip from "@/components/ui/TruncatedTextTooltip.vue";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
import { useSchemaOptions } from "@/composables/useSchemaOptions";
@@ -15,6 +16,7 @@ import { formatDatabaseLabel, isDefaultDatabase } from "@/lib/database/defaultDa
import { connectionDisplayName } from "@/lib/tabs/tabPresentation";
import { isSingleDatabase, supportsSqlInListPaste, supportsTransaction as supportsTransactionFeature } from "@/lib/database/databaseCapabilities";
import { hexToRgba } from "@/lib/common/color";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import type { QueryTab, ConnectionConfig } from "@/types/database";
const props = defineProps<{
@@ -65,6 +67,9 @@ const activeDatabaseOptions = computed(() => {
const connectionOptionIds = computed(() => connectionStore.connections.map((connection) => connection.id));
const activeDatabaseValue = computed(() => props.activeTab.database || "");
const activeProductionContext = computed(() => productionContextForDatabase(props.activeConnection, props.activeTab.database));
const showConnectionProductionBadge = computed(() => activeProductionContext.value.reason === "connection");
const showDatabaseProductionBadge = computed(() => activeProductionContext.value.reason === "database");
const activeConnectionValue = computed(() => props.activeConnection?.id || "");
const activeSchemaValue = computed(() => props.activeTab.schema || "");
const supportsExplain = computed(() => {
@@ -87,6 +92,10 @@ const transactionTooltip = computed(() => {
if (isAgent) return t("toolbar.autoCommitAgent");
return isManual ? t("toolbar.manualTransaction") : t("toolbar.autoCommit");
});
const executeButtonClass = computed(() => {
if (props.activeTab.isExecuting) return "";
return activeProductionContext.value.active ? "bg-red-500/10 text-red-700 hover:bg-red-500/20 hover:text-red-800 dark:text-red-300 dark:hover:text-red-200" : "bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20 hover:text-emerald-800 dark:text-emerald-300 dark:hover:text-emerald-200";
});
const isTransactionActive = computed(() => !!props.txnSessionId);
@@ -144,6 +153,11 @@ function databaseDisplayName(database: string): string {
function connectionById(connectionId: string): ConnectionConfig | undefined {
return connectionStore.getConfig(connectionId);
}
function databaseOptionIsProduction(database: string): boolean {
if (!database || props.activeConnection?.is_production) return false;
return productionContextForDatabase(props.activeConnection, database).reason === "database";
}
</script>
<template>
@@ -155,7 +169,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
:variant="activeTab.isExecuting ? 'destructive' : 'ghost'"
size="icon"
class="h-6 w-6"
:class="activeTab.isExecuting ? '' : 'bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20 hover:text-emerald-800 dark:text-emerald-300 dark:hover:text-emerald-200'"
:class="executeButtonClass"
:disabled="activeTab.isCancelling || activeTab.isExplaining || (!activeTab.isExecuting && !executableSql.trim())"
@click="activeTab.isExecuting ? emit('cancel') : emit('execute')"
>
@@ -318,6 +332,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
<div v-if="activeConnection" class="flex min-w-0 items-center gap-1.5">
<DatabaseIcon :db-type="connectionIconType(activeConnection)" class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ label }}</span>
<ProductionContextBadge v-if="showConnectionProductionBadge" compact />
</div>
<span v-else class="truncate text-muted-foreground">{{ t("editor.selectConnection") }}</span>
</template>
@@ -354,9 +369,13 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
<template #trigger-label="{ label, loading }">
<Database class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ loading ? t("common.loading") : label }}</span>
<ProductionContextBadge v-if="showDatabaseProductionBadge" compact />
</template>
<template #option-label="{ label }">
<TruncatedTextTooltip :text="label" class="min-w-0 flex-1" side="left" :side-offset="8" />
<template #option-label="{ option, label }">
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<TruncatedTextTooltip :text="label" class="min-w-0 flex-1" side="left" :side-offset="8" />
<ProductionContextBadge v-if="databaseOptionIsProduction(option)" compact />
</div>
</template>
</SearchableSelect>
<Tooltip v-if="activeDatabaseValue && !isSingleDb">
@@ -7,12 +7,15 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { ScrollArea } from "@/components/ui/scroll-area";
import { buildCreateExtensionSql, buildDropExtensionSql } from "@/lib/database/dbAdminSql";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { useToast } from "@/composables/useToast";
import { translateBackendError } from "@/i18n/backend-errors";
import type { ExtensionInfo, TreeNode } from "@/types/database";
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const props = defineProps<{
node: TreeNode;
@@ -53,7 +56,14 @@ async function installExtension(name: string) {
installing.value = name;
try {
const sql = buildCreateExtensionSql(name, props.node.schema ?? null);
await api.executeQuery(props.node.connectionId, props.node.database, sql, props.node.schema ?? undefined);
const result = await executeWithProductionSqlGuard({
connection: connectionStore.getConfig(props.node.connectionId),
database: props.node.database,
sql,
source: t("production.sourceExtension"),
execute: () => api.executeQuery(props.node.connectionId!, props.node.database!, sql, props.node.schema ?? undefined),
});
if (!result) return;
await loadData();
emit("close");
} catch (e: any) {
@@ -68,7 +78,14 @@ async function dropExtension(name: string) {
dropping.value = name;
try {
const sql = buildDropExtensionSql(name, false);
await api.executeQuery(props.node.connectionId, props.node.database, sql, props.node.schema ?? undefined);
const result = await executeWithProductionSqlGuard({
connection: connectionStore.getConfig(props.node.connectionId),
database: props.node.database,
sql,
source: t("production.sourceExtension"),
execute: () => api.executeQuery(props.node.connectionId!, props.node.database!, sql, props.node.schema ?? undefined),
});
if (!result) return;
await loadData();
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
@@ -80,6 +80,7 @@ import QueryEditor from "@/components/editor/QueryEditor.vue";
import DdlViewDialog from "./DdlViewDialog.vue";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { batchTableEmptyFeedback, buildBatchTableEmptyPlan, runBatchTableEmpty, type BatchTableEmptyPlanItem } from "@/lib/sidebar/batchTableEmpty";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
@@ -1145,9 +1146,13 @@ async function confirmRename() {
newName,
source: source.source,
});
for (const sql of statements) {
await api.executeQuery(props.connection.id, props.database, sql, schema);
}
const executed = await executeObjectBrowserSqlWithProductionGuard(statements.join(";\n"), async () => {
for (const sql of statements) {
await api.executeQuery(props.connection.id, props.database, sql, schema);
}
return true;
});
if (!executed) return;
} else {
const sql = await buildRenameObjectSql({
databaseType: effectiveDatabaseType.value,
@@ -1156,7 +1161,8 @@ async function confirmRename() {
oldName: row.name,
newName,
});
await api.executeQuery(props.connection.id, props.database, sql, schema);
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql, schema));
if (!executed) return;
}
toast(t("contextMenu.renameObjectSuccess", { oldName: row.name, newName }));
showRenameDialog.value = false;
@@ -1173,7 +1179,8 @@ async function confirmDrop() {
const row = dropTarget.value;
try {
const sql = dropPreviewSql.value || (await buildDropSqlForRow(row, { cascade: canDropTargetCascade.value && dropTableCascade.value }));
await api.executeQuery(props.connection.id, props.database, sql);
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql));
if (!executed) return;
const successKey = row.type === "VIEW" ? "contextMenu.dropViewSuccess" : row.type === "PROCEDURE" ? "contextMenu.dropProcedureSuccess" : row.type === "FUNCTION" ? "contextMenu.dropFunctionSuccess" : "contextMenu.dropTableSuccess";
toast(t(successKey, { name: row.name }));
closeDroppedTableObjectTabsForRow(row);
@@ -1409,11 +1416,20 @@ async function confirmBatchDropTables() {
if (targets.length === 0) return;
try {
const useCascade = canBatchDropCascade.value && batchDropCascade.value;
for (const row of targets) {
const sql = await buildDropTableSql(tableAdminSqlOptions(row, { cascade: useCascade }));
await api.executeQuery(props.connection.id, props.database, sql);
closeDroppedTableObjectTabsForRow(row);
}
const statements = await Promise.all(
targets.map(async (row) => ({
row,
sql: await buildDropTableSql(tableAdminSqlOptions(row, { cascade: useCascade })),
})),
);
const executed = await executeObjectBrowserSqlWithProductionGuard(statements.map(({ sql }) => sql).join(";\n"), async () => {
for (const { row, sql } of statements) {
await api.executeQuery(props.connection.id, props.database, sql);
closeDroppedTableObjectTabsForRow(row);
}
return true;
});
if (!executed) return;
toast(t("objects.batchDropSuccess", { count: targets.length }));
clearTableSelection();
await reload();
@@ -1468,14 +1484,23 @@ async function confirmBatchTruncateTables() {
if (targets.length === 0) return;
try {
const useCascade = canBatchTruncateCascade.value && batchTruncateCascade.value;
await runBatchTableTruncate(
targets,
async (row) => {
const sql = await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: useCascade }));
await api.executeQuery(props.connection.id, props.database, sql);
},
refreshMutatedTableDataTabsForRows,
const statements = await Promise.all(
targets.map(async (row) => ({
row,
sql: await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: useCascade })),
})),
);
const executed = await executeObjectBrowserSqlWithProductionGuard(statements.map(({ sql }) => sql).join(";\n"), async () => {
await runBatchTableTruncate(
statements,
async ({ sql }) => {
await api.executeQuery(props.connection.id, props.database, sql);
},
async (succeeded) => refreshMutatedTableDataTabsForRows(succeeded.map(({ row }) => row)),
);
return true;
});
if (!executed) return;
toast(t("objects.batchTruncateSuccess", { count: targets.length }));
clearTableSelection();
showBatchTruncateConfirm.value = false;
@@ -1696,7 +1721,8 @@ async function confirmDuplicateStructure() {
sourceName: row.name,
targetName: newName,
});
await api.executeQuery(props.connection.id, props.database, sql, schema);
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql, schema));
if (!executed) return;
toast(t("contextMenu.duplicateStructureSuccess", { name: newName }));
await reload();
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, schema);
@@ -1799,7 +1825,8 @@ async function confirmPasteTable() {
sourceName: entry.sourceName,
targetName,
});
await api.executeQuery(props.connection.id, props.database, structureSql, schema);
const executed = await executeObjectBrowserSqlWithProductionGuard(structureSql, () => api.executeQuery(props.connection.id, props.database, structureSql, schema));
if (!executed) return;
}
if (copyData) {
const sourceColumns = await api.getColumns(props.connection.id, props.database, schema || "", entry.sourceName);
@@ -1814,7 +1841,8 @@ async function confirmPasteTable() {
targetName,
...dataCopyColumnOptions,
});
await api.executeQuery(props.connection.id, props.database, dataSql, schema);
const executed = await executeObjectBrowserSqlWithProductionGuard(dataSql, () => api.executeQuery(props.connection.id, props.database, dataSql, schema));
if (!executed) return;
}
successCount++;
} catch (e: any) {
@@ -1841,6 +1869,21 @@ function tableAdminSqlOptions(row: ObjectBrowserRow, options?: { cascade?: boole
return result;
}
/**
* Routes Object Browser writes through the shared production gate. The SQL is
* assessed before the callback runs so generated DDL cannot bypass protection
* via executable comments, EXPLAIN ANALYZE, or qualified production targets.
*/
async function executeObjectBrowserSqlWithProductionGuard<T>(sql: string, execute: () => Promise<T>): Promise<T | undefined> {
return executeWithProductionSqlGuard({
connection: props.connection,
database: props.database,
sql,
source: t("production.sourceObjectBrowser"),
execute,
});
}
async function refreshTruncatePreviewSql(row: ObjectBrowserRow) {
truncatePreviewSql.value = "";
truncatePreviewSql.value = await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: canTruncateTargetCascade.value && truncateTableCascade.value })).catch(() => "");
@@ -1858,7 +1901,8 @@ async function confirmTruncateTable() {
if (!row) return;
try {
const sql = truncatePreviewSql.value || (await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: canTruncateTargetCascade.value && truncateTableCascade.value })));
await api.executeQuery(props.connection.id, props.database, sql);
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql));
if (!executed) return;
toast(t("contextMenu.truncateTableSuccess", { name: row.name }));
await refreshMutatedTableDataTabsForRows([row]);
} catch (e: any) {
@@ -1883,7 +1927,8 @@ async function confirmEmptyTable() {
if (!row) return;
try {
const sql = emptyPreviewSql.value || (await buildEmptyTableSql(tableAdminSqlOptions(row)));
await api.executeQuery(props.connection.id, props.database, sql);
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql));
if (!executed) return;
toast(t("contextMenu.emptyTableSuccess", { name: row.name }));
await refreshMutatedTableDataTabsForRows([row]);
} catch (e: any) {
@@ -1949,8 +1994,23 @@ async function saveSource() {
name: row.name,
source: sourceDraft.value,
});
await executeObjectSourceSave(connectionId, database, effectiveDatabaseType.value, statements, schema);
if (sidePanelGuard.isStale(epoch)) return;
const executableSql = statements.filter((sql) => sql.trim()).join(";\n");
if (executableSql.trim()) {
const saved = await executeWithProductionSqlGuard({
connection: props.connection,
database,
sql: executableSql,
source: t("production.sourceObjectSource"),
execute: async () => {
await executeObjectSourceSave(connectionId, database, effectiveDatabaseType.value, statements, schema);
return true;
},
});
if (!saved || sidePanelGuard.isStale(epoch)) return;
} else {
await executeObjectSourceSave(connectionId, database, effectiveDatabaseType.value, statements, schema);
if (sidePanelGuard.isStale(epoch)) return;
}
toast(t("objects.sourceSaved"));
sourceEditing.value = false;
sourceDraft.value = "";
@@ -3,10 +3,12 @@ import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Clipboard, Loader2, PencilLine, RefreshCw } from "@lucide/vue";
import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { copyToClipboard } from "@/lib/common/clipboard";
import { formatSqlForDisplay, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { buildEditableObjectSource, buildExecutableObjectSourceStatements, executeObjectSourceSave } from "@/lib/table/objectSourceEditor";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import * as api from "@/lib/backend/api";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import { Button } from "@/components/ui/button";
@@ -38,6 +40,7 @@ const emit = defineEmits<{
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const content = ref("");
@@ -133,18 +136,34 @@ async function saveSource() {
return;
}
if (!draft.value.trim() || !props.databaseType) return;
const databaseType = props.databaseType;
const schema = props.schema || props.database;
saving.value = true;
saveError.value = "";
try {
const statements = await buildExecutableObjectSourceStatements({
databaseType: props.databaseType,
databaseType,
objectType: props.objectType,
schema,
name: props.name,
source: draft.value,
});
await executeObjectSourceSave(props.connectionId, props.database, props.databaseType, statements, schema);
const executableSql = statements.filter((sql) => sql.trim()).join(";\n");
if (executableSql.trim()) {
const saved = await executeWithProductionSqlGuard({
connection: connectionStore.getConfig(props.connectionId),
database: props.database,
sql: executableSql,
source: t("production.sourceObjectSource"),
execute: async () => {
await executeObjectSourceSave(props.connectionId, props.database, databaseType, statements, schema);
return true;
},
});
if (!saved) return;
} else {
await executeObjectSourceSave(props.connectionId, props.database, databaseType, statements, schema);
}
toast(t("objects.sourceSaved"));
emit("saved");
await loadSource(false);
@@ -160,6 +160,7 @@ import { rankSavedSqlHistory, type SavedSqlHistoryScope } from "@/lib/savedSql/s
import { isSqlServerLinkedNode } from "@/lib/database/sqlServerLinkedServers";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorIndicator.vue";
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
import { isSchemaAware } from "@/lib/database/databaseFeatureSupport";
import VisibleDatabasesDialog from "@/components/sidebar/VisibleDatabasesDialog.vue";
import SchemaFilterDialog from "@/components/sidebar/VisibleSchemasDialog.vue";
@@ -171,6 +172,8 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import { flattenTree } from "@/composables/useFlatTree";
import { createDatabaseCollationOptionsForCharset, fallbackCreateDatabaseCharsetMetadata, nextCreateDatabaseCollation, normalizeCreateDatabaseCharset, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
const { t } = useI18n();
const labelRef = ref<HTMLElement>();
@@ -272,6 +275,11 @@ const usesFullWidthLabel = computed(() => usesFullWidthTreeLabel(props.node.type
const sidebarTreeContext = inject(sidebarTreeContextKey, null);
const rowWidthClass = computed(() => (usesFullWidthLabel.value ? "w-max min-w-full" : "w-full min-w-0"));
const labelWidthClass = computed(() => (usesFullWidthLabel.value ? "shrink-0 whitespace-nowrap" : "min-w-0 truncate"));
const nodeProductionContext = computed(() => {
const connectionId = props.node.connectionId;
return productionContextForDatabase(connectionId ? connectionStore.getConfig(connectionId) : undefined, props.node.database);
});
const showProductionBadge = computed(() => nodeProductionContext.value.active && ["connection", "database", "redis-db", "mongo-db"].includes(props.node.type));
function currentDatabaseType(): DatabaseType | undefined {
return props.node.connectionId ? effectiveDatabaseTypeForConnection(connectionStore.getConfig(props.node.connectionId)) : undefined;
@@ -2337,6 +2345,18 @@ function openRenameObjectDialog() {
showRenameObjectDialog.value = true;
}
async function executeTreeNodeSqlWithProductionGuard(node: Pick<TreeNode, "connectionId" | "database" | "schema">, sql: string, options: { database?: string; schema?: string } = {}) {
if (!node.connectionId) return undefined;
const database = options.database ?? node.database ?? "";
return executeWithProductionSqlGuard({
connection: connectionStore.getConfig(node.connectionId),
database,
sql,
source: t("production.sourceSidebar"),
execute: () => api.executeQuery(node.connectionId!, database, sql, options.schema ?? node.schema),
});
}
let renameObjectPreviewRequestId = 0;
async function refreshRenameObjectPreviewSql() {
@@ -2390,7 +2410,7 @@ async function confirmRenameObject() {
source: source.source,
});
for (const sql of statements) {
await api.executeQuery(node.connectionId, node.database, sql, schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema });
}
} else {
const sql = await buildRenameObjectSql({
@@ -2400,7 +2420,7 @@ async function confirmRenameObject() {
oldName: node.label,
newName,
});
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
}
toast(t("contextMenu.renameObjectSuccess", { oldName: node.label, newName }), 3000);
showRenameObjectDialog.value = false;
@@ -2418,7 +2438,7 @@ async function confirmDropObject() {
try {
await connectionStore.ensureConnected(node.connectionId);
const sql = dropObjectPreviewSql.value || (await buildDropObjectSql(options));
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
const msgKey = node.type === "view" ? "contextMenu.dropViewSuccess" : node.type === "materialized_view" ? "contextMenu.dropViewSuccess" : node.type === "procedure" ? "contextMenu.dropProcedureSuccess" : "contextMenu.dropFunctionSuccess";
toast(t(msgKey, { name: node.label }), 3000);
closeDroppedTableObjectTabsForNode(node);
@@ -2440,7 +2460,7 @@ async function confirmDropTableChildObject() {
try {
await connectionStore.ensureConnected(node.connectionId);
const sql = dropTableChildObjectPreviewSql.value || (await buildDropTableChildObjectSql(options));
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
toast(t("contextMenu.dropTableChildObjectSuccess", { name: options.name }), 3000);
connectionStore.removeTreeNode(node.id);
} catch (e: any) {
@@ -2484,7 +2504,7 @@ async function confirmBatchDrop() {
await connectionStore.ensureConnected(target.connectionId);
const sql = await dropSqlForTreeNode(target, { cascade: useCascade });
if (!sql) continue;
await api.executeQuery(target.connectionId, target.database, sql, target.schema);
await executeTreeNodeSqlWithProductionGuard(target, sql, { database: target.database, schema: target.schema });
closeDroppedTableObjectTabsForNode(target);
connectionStore.removeTreeNode(target.id);
}
@@ -2507,7 +2527,8 @@ async function confirmBatchTruncate() {
await connectionStore.ensureConnected(target.connectionId);
const sql = await truncateSqlForTreeNode(target, { cascade: useCascade });
if (!sql) return false;
await api.executeQuery(target.connectionId, target.database, sql, target.schema);
const result = await executeTreeNodeSqlWithProductionGuard(target, sql, { database: target.database, schema: target.schema });
return result === undefined ? false : undefined;
},
refreshMutatedTableDataTabsForNodes,
);
@@ -2527,7 +2548,7 @@ async function confirmBatchEmpty() {
await connectionStore.ensureConnected(target.connectionId);
const sql = await emptySqlForTreeNode(target);
if (!sql) throw new Error("Empty table SQL is unavailable");
await api.executeQuery(target.connectionId, target.database, sql, target.schema);
await executeTreeNodeSqlWithProductionGuard(target, sql, { database: target.database, schema: target.schema });
});
for (const failure of result.failed) {
console.error(`Failed to empty table "${failure.target.label}":`, failure.error);
@@ -2719,7 +2740,7 @@ async function confirmDropTable() {
try {
await connectionStore.ensureConnected(node.connectionId);
const sql = dropTablePreviewSql.value || (await buildDropTableSql(dropTableSqlOptions()));
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
toast(t("contextMenu.dropTableSuccess", { name: node.label }), 3000);
closeDroppedTableObjectTabsForNode(node);
connectionStore.removeTreeNode(node.id);
@@ -2739,7 +2760,7 @@ async function confirmEmptyTable() {
try {
await connectionStore.ensureConnected(node.connectionId);
const sql = emptyTablePreviewSql.value || (await buildEmptyTableSql(tableAdminSqlOptions()));
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
const messageKey = currentDatabaseType() === "clickhouse" ? "contextMenu.emptyTableSubmitted" : "contextMenu.emptyTableSuccess";
toast(t(messageKey, { name: node.label }), 3000);
await refreshMutatedTableDataTabsForNode(node);
@@ -2760,7 +2781,7 @@ async function confirmTruncateTable() {
try {
await connectionStore.ensureConnected(node.connectionId);
const sql = truncateTablePreviewSql.value || (await buildTruncateTableSql(truncateTableSqlOptions()));
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
toast(t("contextMenu.truncateTableSuccess", { name: node.label }), 3000);
await refreshMutatedTableDataTabsForNode(node);
} catch (e: any) {
@@ -2895,7 +2916,7 @@ async function confirmEditDatabaseProperties() {
const options = databasePropertyEditOptions();
if (!options) return;
const sql = await buildUpdateDatabasePropertiesSql(options);
await api.executeQuery(node.connectionId, databasePropertyName(), sql);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: databasePropertyName() });
toast(t("contextMenu.editDatabasePropertiesSuccess", { name: node.label }), 3000);
showEditDatabasePropertiesDialog.value = false;
await connectionStore.loadDatabases(node.connectionId, { force: true });
@@ -2968,7 +2989,7 @@ async function confirmEditSchemaComment() {
name: node.schema || node.label,
comment: schemaCommentText.value,
});
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
toast(t("contextMenu.editSchemaCommentSuccess", { name: node.label }), 3000);
showEditSchemaCommentDialog.value = false;
await connectionStore.loadSchemas(node.connectionId, node.database, { force: true });
@@ -3143,7 +3164,8 @@ async function createDuckDbAttachedDatabaseFile() {
duckDbAttachedDatabaseNameFromPath(path),
existingDatabases.map((database) => database.name),
);
await api.executeQuery(node.connectionId, "", await buildDuckDbAttachDatabaseSql(path, name));
const sql = await buildDuckDbAttachDatabaseSql(path, name);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" });
const config = connectionStore.getConfig(node.connectionId);
if (config) {
@@ -3184,7 +3206,7 @@ async function confirmCreateDatabase() {
charset: createDatabaseCharset.value,
collation: createDatabaseCollation.value,
});
await api.executeQuery(node.connectionId, "", sql);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" });
toast(t("contextMenu.createDatabaseSuccess", { name }), 3000);
await connectionStore.ensureVisibleDatabase(node.connectionId, name);
await connectionStore.loadDatabases(node.connectionId, { force: true });
@@ -3255,7 +3277,7 @@ async function confirmDropDatabase() {
databaseType: currentDatabaseType(),
name: node.label,
}));
await api.executeQuery(node.connectionId, "", sql);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" });
toast(t("contextMenu.dropDatabaseSuccess", { name: node.label }), 3000);
await connectionStore.loadDatabases(node.connectionId, { force: true });
showDropDatabaseConfirm.value = false;
@@ -3349,7 +3371,7 @@ async function confirmCreateSchema() {
databaseType: effectiveDatabaseTypeForConnection(config),
name,
});
await api.executeQuery(node.connectionId, targetDatabase || "", sql);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: targetDatabase || "" });
toast(t("contextMenu.createSchemaSuccess", { name }), 3000);
if (isConnectionLevelSchemaCreation) {
await connectionStore.loadDatabases(node.connectionId, { force: true });
@@ -3379,7 +3401,7 @@ async function confirmDropSchema() {
databaseType: currentDatabaseType(),
name: node.label,
}));
await api.executeQuery(node.connectionId, node.database, sql);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database });
toast(t("contextMenu.dropSchemaSuccess", { name: node.label }), 3000);
const config = connectionStore.getConfig(node.connectionId);
if (config?.db_type === "sqlserver") {
@@ -3417,7 +3439,7 @@ async function confirmDuplicateStructure() {
sourceName: node.label,
targetName: newName,
});
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
toast(t("contextMenu.duplicateStructureSuccess", { name: newName }), 3000);
await refreshTableList(node);
} catch (e: any) {
@@ -3446,7 +3468,7 @@ async function confirmPasteTable() {
sourceName: entry.sourceName,
targetName,
});
await api.executeQuery(entry.connectionId, entry.database, structureSql, entry.schema);
await executeTreeNodeSqlWithProductionGuard(entry, structureSql, { database: entry.database, schema: entry.schema });
}
if (copyData) {
const sourceColumns = await api.getColumns(entry.connectionId, entry.database, entry.schema || "", entry.sourceName);
@@ -3461,7 +3483,7 @@ async function confirmPasteTable() {
targetName,
...dataCopyColumnOptions,
});
await api.executeQuery(entry.connectionId, entry.database, dataSql, entry.schema);
await executeTreeNodeSqlWithProductionGuard(entry, dataSql, { database: entry.database, schema: entry.schema });
}
successCount++;
const refreshKey = `${entry.connectionId}:${entry.database}:${entry.schema || ""}`;
@@ -5323,6 +5345,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
@click.stop
/>
<span v-else ref="labelRef" :class="labelWidthClass">{{ visibleLabel(node) }}</span>
<ProductionContextBadge v-if="showProductionBadge" compact />
<span
v-if="
(node.type === 'group-tables' || node.type === 'group-views' || node.type === 'group-materialized-views' || node.type === 'group-procedures' || node.type === 'group-functions' || node.type === 'group-sequences' || node.type === 'group-packages' || node.type === 'group-partitions') &&
@@ -12,6 +12,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
import { requiresSqlFileTargetDatabaseSelection } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { cancelSqlFileExecution, executeSqlFile, listenSqlFileProgress, listDatabases, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus } from "@/lib/backend/api";
@@ -31,6 +33,7 @@ const props = defineProps<{
}>();
const store = useConnectionStore();
const productionSafetyStore = useProductionSafetyStore();
const fileInput = ref<HTMLInputElement | null>(null);
const filePath = ref("");
@@ -280,6 +283,18 @@ async function refreshTargetAfterImport() {
async function startExecution() {
if (!canStart.value || !preview.value) return;
const productionContext = productionContextForDatabase(selectedConnection.value, database.value);
if (productionContext.active) {
// File previews are truncated, so production file execution is always reviewed instead of inferring safety from a partial preview.
const confirmed = await productionSafetyStore.requestConfirmation({
sql: preview.value.preview,
connectionName: selectedConnection.value?.name,
database: database.value,
productionDatabases: productionContext.databases,
source: t("production.sourceSqlFile"),
});
if (!confirmed) return;
}
const id = uuid();
executionId.value = id;
@@ -13,6 +13,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useConnectionStore } from "@/stores/connectionStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { useQueryStore } from "@/stores/queryStore";
import { useHistoryStore } from "@/stores/historyStore";
import { useSettingsStore, type StructureEditorDensity } from "@/stores/settingsStore";
@@ -64,6 +66,7 @@ import * as api from "@/lib/backend/api";
const { t } = useI18n();
const { isDark } = useTheme();
const store = useConnectionStore();
const productionSafetyStore = useProductionSafetyStore();
const queryStore = useQueryStore();
const historyStore = useHistoryStore();
const settingsStore = useSettingsStore();
@@ -2061,13 +2064,24 @@ function toggleSqlPreviewCollapsed() {
async function applyChanges() {
if (!canApply.value || !props.connectionId || !props.database) return;
const sql = previewSqlText.value;
const connection = store.getConfig(props.connectionId);
const productionContext = productionContextForDatabase(connection, props.database);
if (productionContext.active) {
const confirmed = await productionSafetyStore.requestConfirmation({
sql,
connectionName: connection?.name,
database: props.database,
productionDatabases: productionContext.databases,
source: t("production.sourceStructure"),
});
if (!confirmed) return;
}
saving.value = true;
errorMessage.value = "";
const sql = previewSqlText.value;
const refreshScope = captureStructureRefreshScope();
const startedAt = Date.now();
try {
const connection = store.getConfig(props.connectionId);
const result = hasSqliteTypeChange.value
? await api.applySqliteTableStructureChange(props.connectionId, props.database, structureChangeOptions(), sqliteSchemaRevision.value!)
: await api.executeBatch(props.connectionId, props.database, pendingStatements.value, props.schema, queryTimeoutSecsForConnection(connection));