mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(diff): save, rename, copy, and delete data compare configs
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Copy, FileDown, Plus, Trash2 } from "@lucide/vue";
|
||||
import type { DataCompareConfig } from "@/composables/useDataCompareConfig";
|
||||
|
||||
const props = defineProps<{
|
||||
configs: DataCompareConfig[];
|
||||
activeConfigId: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:activeConfigId", id: string): void;
|
||||
(e: "create"): void;
|
||||
(e: "rename", id: string, name: string): void;
|
||||
(e: "delete", id: string): void;
|
||||
(e: "duplicate", id: string): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const renameDialogOpen = ref(false);
|
||||
const renameValue = ref("");
|
||||
const renamingId = ref("");
|
||||
|
||||
const activeConfig = computed(() => props.configs.find((config) => config.id === props.activeConfigId) ?? null);
|
||||
|
||||
function onSelectChange(value: unknown) {
|
||||
emit("update:activeConfigId", String(value));
|
||||
}
|
||||
|
||||
function startRename(config: DataCompareConfig) {
|
||||
renamingId.value = config.id;
|
||||
renameValue.value = config.name;
|
||||
renameDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function confirmRename() {
|
||||
if (renamingId.value && renameValue.value.trim()) {
|
||||
emit("rename", renamingId.value, renameValue.value.trim());
|
||||
}
|
||||
renameDialogOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Select :model-value="activeConfigId" :disabled="disabled" @update:model-value="onSelectChange">
|
||||
<SelectTrigger class="h-8 min-w-[200px] text-xs" data-testid="data-compare-config-select">
|
||||
<SelectValue :placeholder="t('dataCompare.configSelect')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="config in configs" :key="config.id" :value="config.id"> {{ config.name }} ({{ t("dataCompare.configTableCount", { count: config.selectedSourceTables.length }) }}) </SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" size="icon" class="h-8 w-8" :disabled="disabled" :title="t('dataCompare.configNew')" @click="emit('create')">
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button v-if="activeConfig" variant="outline" size="icon" class="h-8 w-8" :disabled="disabled" :title="t('dataCompare.configRename')" @click="startRename(activeConfig)">
|
||||
<Copy class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button v-if="activeConfig" variant="outline" size="icon" class="h-8 w-8" :disabled="disabled" :title="t('dataCompare.configDuplicate')" @click="emit('duplicate', activeConfig.id)">
|
||||
<FileDown class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button v-if="activeConfig" variant="outline" size="icon" class="h-8 w-8" :disabled="disabled" :title="t('dataCompare.configDelete')" @click="emit('delete', activeConfig.id)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Dialog v-model:open="renameDialogOpen">
|
||||
<DialogContent class="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("dataCompare.configRename") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="py-4">
|
||||
<Label class="text-sm">{{ t("dataCompare.configName") }}</Label>
|
||||
<Input v-model="renameValue" class="mt-2" @keydown.enter="confirmRename" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="renameDialogOpen = false">{{ t("common.cancel") }}</Button>
|
||||
<Button @click="confirmRename">{{ t("common.save") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
type SelectableDataCompareRow,
|
||||
} from "@/composables/useDataCompareSession";
|
||||
import CompareKeyColumnsSelect from "@/components/diff/CompareKeyColumnsSelect.vue";
|
||||
import DataCompareConfigSelector from "@/components/diff/DataCompareConfigSelector.vue";
|
||||
import { useDataCompareConfig, type DataCompareConfig, type DataCompareConfigSnapshot } from "@/composables/useDataCompareConfig";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { supportsTransaction } from "@/lib/database/databaseFeatureSupport";
|
||||
@@ -41,6 +43,16 @@ const SYNC_EXECUTE_BATCH_SIZE = 500;
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const store = useConnectionStore();
|
||||
const {
|
||||
configs: dataCompareConfigs,
|
||||
activeConfigId: dataCompareConfigId,
|
||||
activeConfig: activeDataCompareConfig,
|
||||
createConfig: createDataCompareConfig,
|
||||
updateConfig: updateDataCompareConfig,
|
||||
renameConfig: renameDataCompareConfig,
|
||||
deleteConfig: deleteDataCompareConfig,
|
||||
duplicateConfig: duplicateDataCompareConfig,
|
||||
} = useDataCompareConfig();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -111,6 +123,9 @@ const activeSessionId = ref<string | null>(props.sessionId ?? null);
|
||||
let syncPlanRequestId = 0;
|
||||
let initializingPrefill = false;
|
||||
let initializingPrefillGeneration = 0;
|
||||
/** Set while a saved config is being applied so programmatic form writes are not re-saved. */
|
||||
let suppressConfigSave = false;
|
||||
let configSaveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let componentUnmounted = false;
|
||||
let shownSessionError = "";
|
||||
|
||||
@@ -457,6 +472,103 @@ async function restoreDataCompareSession(session: DataCompareSession): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
/** Selection-only snapshot persisted by the config selector. */
|
||||
function dataCompareConfigSnapshot(): DataCompareConfigSnapshot {
|
||||
return {
|
||||
sourceConnectionId: sourceConnectionId.value,
|
||||
sourceDatabase: sourceDatabase.value,
|
||||
sourceSchema: sourceSchema.value,
|
||||
selectedSourceTables: [...selectedSourceTables.value],
|
||||
targetConnectionId: targetConnectionId.value,
|
||||
targetDatabase: targetDatabase.value,
|
||||
targetSchema: targetSchema.value,
|
||||
targetTable: targetTable.value,
|
||||
detailPreviewLimit: detailPreviewLimit.value,
|
||||
keyColumnsByTable: buildSessionKeyColumnsByTable(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the dialog selection from a saved config. Option lists are re-read
|
||||
* from the live connection (never restored from storage) so a config can not
|
||||
* resurrect a table that no longer exists; the saved table selection is then
|
||||
* intersected with what the connection actually returns.
|
||||
*/
|
||||
async function applyDataCompareConfig(config: DataCompareConfig): Promise<void> {
|
||||
if (comparing.value || executionLocked.value) return;
|
||||
const generation = ++initializingPrefillGeneration;
|
||||
initializingPrefill = true;
|
||||
suppressConfigSave = true;
|
||||
clearResult();
|
||||
shownSessionError = "";
|
||||
try {
|
||||
sourceConnectionId.value = config.sourceConnectionId;
|
||||
if (!config.sourceConnectionId) return;
|
||||
await loadDatabases(config.sourceConnectionId, "source");
|
||||
if (generation !== initializingPrefillGeneration) return;
|
||||
if (config.sourceDatabase) sourceDatabase.value = config.sourceDatabase;
|
||||
if (config.sourceDatabase) await loadSchemas("source", config.sourceSchema);
|
||||
if (generation !== initializingPrefillGeneration) return;
|
||||
|
||||
targetConnectionId.value = config.targetConnectionId;
|
||||
if (config.targetConnectionId) {
|
||||
await loadDatabases(config.targetConnectionId, "target");
|
||||
if (generation !== initializingPrefillGeneration) return;
|
||||
if (config.targetDatabase) targetDatabase.value = config.targetDatabase;
|
||||
if (config.targetDatabase) await loadSchemas("target", config.targetSchema);
|
||||
if (generation !== initializingPrefillGeneration) return;
|
||||
}
|
||||
|
||||
resetSelectedSourceTables(config.selectedSourceTables);
|
||||
if (sourceSchema.value) await loadTables("source");
|
||||
if (targetSchema.value) await loadTables("target");
|
||||
if (generation !== initializingPrefillGeneration) return;
|
||||
// loadTables prefers the dialog's prefill table when present; the saved
|
||||
// config must win, so re-apply the selection and drop tables that vanished.
|
||||
const availableTables = config.selectedSourceTables.filter((table) => sourceTables.value.includes(table));
|
||||
resetSelectedSourceTables(availableTables);
|
||||
sourceTable.value = availableTables.length === 1 ? (availableTables[0] ?? "") : "";
|
||||
if (config.targetTable && targetTables.value.includes(config.targetTable)) targetTable.value = config.targetTable;
|
||||
keyColumnOverrides.value = normalizeKeyColumnOverrides(config.keyColumnsByTable);
|
||||
resetTableColumnMetadata();
|
||||
if (config.detailPreviewLimit) detailPreviewLimit.value = config.detailPreviewLimit;
|
||||
void prefetchSelectedTableColumns(selectedSourceTableNames.value);
|
||||
} catch (error) {
|
||||
toast(String(error), 5000);
|
||||
} finally {
|
||||
await nextTick();
|
||||
if (generation === initializingPrefillGeneration) initializingPrefill = false;
|
||||
suppressConfigSave = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDataCompareConfigSelect(id: string) {
|
||||
dataCompareConfigId.value = id;
|
||||
const config = dataCompareConfigs.value.find((item) => item.id === id);
|
||||
if (config) void applyDataCompareConfig(config);
|
||||
}
|
||||
|
||||
function handleSaveDataCompareConfig() {
|
||||
createDataCompareConfig(t("dataCompare.defaultConfigName"), dataCompareConfigSnapshot());
|
||||
toast(t("dataCompare.configSaved"), 3000);
|
||||
}
|
||||
|
||||
function handleRenameDataCompareConfig(id: string, name: string) {
|
||||
renameDataCompareConfig(id, name);
|
||||
}
|
||||
|
||||
function handleDuplicateDataCompareConfig() {
|
||||
const id = dataCompareConfigId.value;
|
||||
if (id) duplicateDataCompareConfig(id);
|
||||
}
|
||||
|
||||
function handleDeleteDataCompareConfig() {
|
||||
const id = dataCompareConfigId.value;
|
||||
// The form keeps the current selection; only the saved entry is removed so a
|
||||
// delete can never clear what the user is looking at.
|
||||
if (id) deleteDataCompareConfig(id);
|
||||
}
|
||||
|
||||
function applyDataCompareSession(session: DataCompareSession | undefined): void {
|
||||
if (!session) return;
|
||||
batchResults.value = session.batchResults;
|
||||
@@ -1002,6 +1114,21 @@ watch(targetTable, () => {
|
||||
if (initializingPrefill) return;
|
||||
clearResult();
|
||||
});
|
||||
// Keep the selected config in sync with the form so "saved" really means the
|
||||
// current selection. Debounced because table multi-select toggles fire rapidly.
|
||||
watch(
|
||||
() => [sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, sourceTableSelection.value.join("\u0000"), targetConnectionId.value, targetDatabase.value, targetSchema.value, targetTable.value, detailPreviewLimit.value, JSON.stringify(keyColumnOverrides.value)],
|
||||
() => {
|
||||
if (suppressConfigSave || initializingPrefill || !dataCompareConfigId.value) return;
|
||||
if (configSaveTimer) clearTimeout(configSaveTimer);
|
||||
const configId = dataCompareConfigId.value;
|
||||
configSaveTimer = setTimeout(() => {
|
||||
configSaveTimer = undefined;
|
||||
if (componentUnmounted || configId !== dataCompareConfigId.value) return;
|
||||
updateDataCompareConfig(configId, dataCompareConfigSnapshot());
|
||||
}, 400);
|
||||
},
|
||||
);
|
||||
watch(
|
||||
[() => open.value, () => props.sessionId],
|
||||
async ([value, sessionId]) => {
|
||||
@@ -1025,6 +1152,10 @@ watch(
|
||||
if (props.prefillConnectionId) {
|
||||
const generation = ++initializingPrefillGeneration;
|
||||
initializingPrefill = true;
|
||||
// A prefill is an ad-hoc compare started from another screen; it must not
|
||||
// be silently written into whichever config happened to be selected.
|
||||
dataCompareConfigId.value = "";
|
||||
suppressConfigSave = true;
|
||||
try {
|
||||
sourceConnectionId.value = props.prefillConnectionId;
|
||||
await loadDatabases(props.prefillConnectionId, "source");
|
||||
@@ -1041,7 +1172,13 @@ watch(
|
||||
} finally {
|
||||
await nextTick();
|
||||
if (generation === initializingPrefillGeneration) initializingPrefill = false;
|
||||
suppressConfigSave = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const savedConfig = activeDataCompareConfig.value;
|
||||
if (savedConfig) {
|
||||
await applyDataCompareConfig(savedConfig);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -1058,6 +1195,7 @@ watch(
|
||||
);
|
||||
onBeforeUnmount(() => {
|
||||
componentUnmounted = true;
|
||||
if (configSaveTimer) clearTimeout(configSaveTimer);
|
||||
if (!executing.value && txnSessionId.value) void finishTransaction(false);
|
||||
});
|
||||
</script>
|
||||
@@ -1072,6 +1210,19 @@ onBeforeUnmount(() => {
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 border-b pb-2">
|
||||
<DataCompareConfigSelector
|
||||
:configs="dataCompareConfigs"
|
||||
:active-config-id="dataCompareConfigId"
|
||||
:disabled="comparing || executionLocked"
|
||||
@update:active-config-id="handleDataCompareConfigSelect"
|
||||
@create="handleSaveDataCompareConfig"
|
||||
@rename="handleRenameDataCompareConfig"
|
||||
@duplicate="handleDuplicateDataCompareConfig"
|
||||
@delete="handleDeleteDataCompareConfig"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 min-w-0 overflow-auto">
|
||||
<fieldset :disabled="executionLocked" class="space-y-4 py-2">
|
||||
<div class="grid grid-cols-[1fr_auto_1fr] gap-4 items-start">
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const CONFIG_KEY = "dbx-data-compare-configs";
|
||||
|
||||
function snapshot(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
sourceConnectionId: "src",
|
||||
sourceDatabase: "app",
|
||||
sourceSchema: "public",
|
||||
selectedSourceTables: ["users", "orders"],
|
||||
targetConnectionId: "dst",
|
||||
targetDatabase: "app_copy",
|
||||
targetSchema: "public",
|
||||
targetTable: "users",
|
||||
detailPreviewLimit: "100",
|
||||
keyColumnsByTable: { users: ["user_id"] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useDataCompareConfig", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("creates a config and persists the whole selection", async () => {
|
||||
const { useDataCompareConfig } = await import("../useDataCompareConfig");
|
||||
const state = useDataCompareConfig();
|
||||
|
||||
const created = state.createConfig("Nightly", snapshot());
|
||||
|
||||
expect(state.activeConfigId.value).toBe(created.id);
|
||||
expect(state.activeConfig.value?.name).toBe("Nightly");
|
||||
const stored = JSON.parse(localStorage.getItem(CONFIG_KEY) ?? "[]") as Array<Record<string, unknown>>;
|
||||
expect(stored).toHaveLength(1);
|
||||
expect(stored[0]!.selectedSourceTables).toEqual(["users", "orders"]);
|
||||
expect(stored[0]!.keyColumnsByTable).toEqual({ users: ["user_id"] });
|
||||
});
|
||||
|
||||
it("keeps config names unique instead of silently overwriting", async () => {
|
||||
const { useDataCompareConfig } = await import("../useDataCompareConfig");
|
||||
const state = useDataCompareConfig();
|
||||
|
||||
state.createConfig("Same", snapshot());
|
||||
state.createConfig("Same", snapshot());
|
||||
|
||||
expect(state.configs.value.map((config) => config.name)).toEqual(["Same (1)", "Same"]);
|
||||
state.renameConfig(state.configs.value[1]!.id, "Same (1)");
|
||||
expect(state.configs.value.map((config) => config.name)).toEqual(["Same (1)", "Same (1) (1)"]);
|
||||
});
|
||||
|
||||
it("drops the active selection when the active config is deleted", async () => {
|
||||
const { useDataCompareConfig } = await import("../useDataCompareConfig");
|
||||
const state = useDataCompareConfig();
|
||||
const created = state.createConfig("One", snapshot());
|
||||
|
||||
state.deleteConfig(created.id);
|
||||
|
||||
expect(state.configs.value).toHaveLength(0);
|
||||
expect(state.activeConfigId.value).toBe("");
|
||||
expect(state.activeConfig.value).toBeNull();
|
||||
});
|
||||
|
||||
it("duplicates a config under a new id without sharing mutable arrays", async () => {
|
||||
const { useDataCompareConfig } = await import("../useDataCompareConfig");
|
||||
const state = useDataCompareConfig();
|
||||
const created = state.createConfig("Base", snapshot());
|
||||
|
||||
const copy = state.duplicateConfig(created.id);
|
||||
|
||||
expect(copy?.id).not.toBe(created.id);
|
||||
expect(copy?.name).toBe("Base (1)");
|
||||
expect(state.activeConfigId.value).toBe(copy?.id);
|
||||
copy!.selectedSourceTables.push("logs");
|
||||
expect(created.selectedSourceTables).toEqual(["users", "orders"]);
|
||||
});
|
||||
|
||||
it("updates a config in place and bumps updatedAt", async () => {
|
||||
const { useDataCompareConfig } = await import("../useDataCompareConfig");
|
||||
const state = useDataCompareConfig();
|
||||
const created = state.createConfig("One", snapshot());
|
||||
created.updatedAt = 1;
|
||||
|
||||
state.updateConfig(created.id, { selectedSourceTables: ["logs"], keyColumnsByTable: {} });
|
||||
|
||||
const updated = state.configs.value.find((config) => config.id === created.id);
|
||||
expect(updated?.selectedSourceTables).toEqual(["logs"]);
|
||||
expect(updated?.keyColumnsByTable).toEqual({});
|
||||
expect(updated?.updatedAt).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("sanitizes stored configs and preserves an explicitly cleared match-column list", async () => {
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify([{ name: "Valid", id: "a", selectedSourceTables: ["t", 5], keyColumnsByTable: { t: [], u: "bad" } }, { id: "no-name", name: "" }, "not-an-object"]));
|
||||
|
||||
const { useDataCompareConfig } = await import("../useDataCompareConfig");
|
||||
const state = useDataCompareConfig();
|
||||
|
||||
expect(state.configs.value).toHaveLength(1);
|
||||
expect(state.configs.value[0]!.selectedSourceTables).toEqual(["t"]);
|
||||
expect(state.configs.value[0]!.keyColumnsByTable).toEqual({ t: [] });
|
||||
expect(state.configs.value[0]!.detailPreviewLimit).toBe("");
|
||||
});
|
||||
|
||||
it("recovers from unreadable stored JSON", async () => {
|
||||
localStorage.setItem(CONFIG_KEY, "{not json");
|
||||
|
||||
const { useDataCompareConfig } = await import("../useDataCompareConfig");
|
||||
const state = useDataCompareConfig();
|
||||
|
||||
expect(state.configs.value).toEqual([]);
|
||||
expect(state.activeConfig.value).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
|
||||
/**
|
||||
* Persisted "Compare Data" task configuration.
|
||||
*
|
||||
* Only the user's selection is stored (endpoints + the tables to compare +
|
||||
* per-table match columns). Derived data such as the available database /
|
||||
* schema / table lists, compare results and sync plans are intentionally not
|
||||
* persisted: they are re-loaded from the live connection when a config is
|
||||
* applied, so a stale config can never resurrect a deleted table.
|
||||
*/
|
||||
export interface DataCompareConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
sourceConnectionId: string;
|
||||
sourceDatabase: string;
|
||||
sourceSchema: string;
|
||||
selectedSourceTables: string[];
|
||||
targetConnectionId: string;
|
||||
targetDatabase: string;
|
||||
targetSchema: string;
|
||||
targetTable: string;
|
||||
/** Preview row limit, stored alongside the selection so re-running reproduces the same view. */
|
||||
detailPreviewLimit: string;
|
||||
/** Explicit match columns per source table; absent entry means "use the primary key". */
|
||||
keyColumnsByTable: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface DataCompareConfigSnapshot {
|
||||
sourceConnectionId: string;
|
||||
sourceDatabase: string;
|
||||
sourceSchema: string;
|
||||
selectedSourceTables: string[];
|
||||
targetConnectionId: string;
|
||||
targetDatabase: string;
|
||||
targetSchema: string;
|
||||
targetTable: string;
|
||||
detailPreviewLimit: string;
|
||||
keyColumnsByTable: Record<string, string[]>;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "dbx-data-compare-configs";
|
||||
const MAX_CONFIGS = 50;
|
||||
|
||||
const configs = ref<DataCompareConfig[]>(loadConfigsFromStorage());
|
||||
const activeConfigId = ref("");
|
||||
|
||||
function sanitizeStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.length > 0) : [];
|
||||
}
|
||||
|
||||
function sanitizeKeyColumns(value: unknown): Record<string, string[]> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const [table, columns] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof table !== "string" || !table) continue;
|
||||
// Keep an explicitly empty array: it means "the user cleared the match
|
||||
// columns" and must not silently fall back to the primary key.
|
||||
if (Array.isArray(columns)) result[table] = sanitizeStringArray(columns);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sanitizeConfig(raw: unknown): DataCompareConfig | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const item = raw as Record<string, unknown>;
|
||||
const id = typeof item.id === "string" && item.id ? item.id : uuid();
|
||||
const name = typeof item.name === "string" && item.name.trim() ? item.name : "";
|
||||
if (!name) return undefined;
|
||||
const createdAt = typeof item.createdAt === "number" ? item.createdAt : Date.now();
|
||||
const updatedAt = typeof item.updatedAt === "number" ? item.updatedAt : createdAt;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
sourceConnectionId: typeof item.sourceConnectionId === "string" ? item.sourceConnectionId : "",
|
||||
sourceDatabase: typeof item.sourceDatabase === "string" ? item.sourceDatabase : "",
|
||||
sourceSchema: typeof item.sourceSchema === "string" ? item.sourceSchema : "",
|
||||
selectedSourceTables: sanitizeStringArray(item.selectedSourceTables),
|
||||
targetConnectionId: typeof item.targetConnectionId === "string" ? item.targetConnectionId : "",
|
||||
targetDatabase: typeof item.targetDatabase === "string" ? item.targetDatabase : "",
|
||||
targetSchema: typeof item.targetSchema === "string" ? item.targetSchema : "",
|
||||
targetTable: typeof item.targetTable === "string" ? item.targetTable : "",
|
||||
detailPreviewLimit: typeof item.detailPreviewLimit === "string" ? item.detailPreviewLimit : "",
|
||||
keyColumnsByTable: sanitizeKeyColumns(item.keyColumnsByTable),
|
||||
};
|
||||
}
|
||||
|
||||
function loadConfigsFromStorage(): DataCompareConfig[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.map(sanitizeConfig).filter((config): config is DataCompareConfig => Boolean(config));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveConfigsToStorage() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(configs.value));
|
||||
} catch {
|
||||
// Quota/private-mode failures must never break the compare dialog; the
|
||||
// in-memory configs keep working for the current session.
|
||||
}
|
||||
}
|
||||
|
||||
export function useDataCompareConfig() {
|
||||
const activeConfig = computed(() => configs.value.find((config) => config.id === activeConfigId.value) ?? null);
|
||||
|
||||
function uniqueName(base: string, excludeId = ""): string {
|
||||
const trimmed = base.trim() || "Config";
|
||||
if (!configs.value.some((config) => config.name === trimmed && config.id !== excludeId)) return trimmed;
|
||||
let counter = 1;
|
||||
let candidate = `${trimmed} (${counter})`;
|
||||
while (configs.value.some((config) => config.name === candidate && config.id !== excludeId)) {
|
||||
counter++;
|
||||
candidate = `${trimmed} (${counter})`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function createConfig(name: string, snapshot: DataCompareConfigSnapshot): DataCompareConfig {
|
||||
const now = Date.now();
|
||||
const config: DataCompareConfig = {
|
||||
...snapshot,
|
||||
selectedSourceTables: [...snapshot.selectedSourceTables],
|
||||
keyColumnsByTable: sanitizeKeyColumns(snapshot.keyColumnsByTable),
|
||||
id: uuid(),
|
||||
name: uniqueName(name),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
configs.value = [config, ...configs.value].slice(0, MAX_CONFIGS);
|
||||
activeConfigId.value = config.id;
|
||||
saveConfigsToStorage();
|
||||
return config;
|
||||
}
|
||||
|
||||
function updateConfig(id: string, patch: Partial<DataCompareConfigSnapshot>) {
|
||||
const index = configs.value.findIndex((config) => config.id === id);
|
||||
if (index === -1) return;
|
||||
const next: DataCompareConfig = {
|
||||
...configs.value[index]!,
|
||||
...patch,
|
||||
...(patch.selectedSourceTables ? { selectedSourceTables: [...patch.selectedSourceTables] } : {}),
|
||||
...(patch.keyColumnsByTable ? { keyColumnsByTable: sanitizeKeyColumns(patch.keyColumnsByTable) } : {}),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
configs.value = configs.value.map((config, position) => (position === index ? next : config));
|
||||
saveConfigsToStorage();
|
||||
}
|
||||
|
||||
function renameConfig(id: string, name: string) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
const index = configs.value.findIndex((config) => config.id === id);
|
||||
if (index === -1) return;
|
||||
configs.value = configs.value.map((config) => (config.id === id ? { ...config, name: uniqueName(trimmed, id), updatedAt: Date.now() } : config));
|
||||
saveConfigsToStorage();
|
||||
}
|
||||
|
||||
function deleteConfig(id: string) {
|
||||
configs.value = configs.value.filter((config) => config.id !== id);
|
||||
if (activeConfigId.value === id) activeConfigId.value = "";
|
||||
saveConfigsToStorage();
|
||||
}
|
||||
|
||||
function duplicateConfig(id: string): DataCompareConfig | undefined {
|
||||
const source = configs.value.find((config) => config.id === id);
|
||||
if (!source) return undefined;
|
||||
const now = Date.now();
|
||||
const copy: DataCompareConfig = {
|
||||
...source,
|
||||
id: uuid(),
|
||||
name: uniqueName(source.name),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
selectedSourceTables: [...source.selectedSourceTables],
|
||||
keyColumnsByTable: sanitizeKeyColumns(source.keyColumnsByTable),
|
||||
};
|
||||
configs.value = [copy, ...configs.value].slice(0, MAX_CONFIGS);
|
||||
activeConfigId.value = copy.id;
|
||||
saveConfigsToStorage();
|
||||
return copy;
|
||||
}
|
||||
|
||||
function clearActiveConfig() {
|
||||
activeConfigId.value = "";
|
||||
}
|
||||
|
||||
watch(configs, saveConfigsToStorage, { deep: true });
|
||||
|
||||
return {
|
||||
configs: computed(() => configs.value),
|
||||
activeConfigId,
|
||||
activeConfig,
|
||||
createConfig,
|
||||
updateConfig,
|
||||
renameConfig,
|
||||
deleteConfig,
|
||||
duplicateConfig,
|
||||
clearActiveConfig,
|
||||
};
|
||||
}
|
||||
@@ -6793,6 +6793,15 @@ export default withEnglishFallback({
|
||||
noTables: "Mövcud cədvəl yoxdur",
|
||||
},
|
||||
dataCompare: {
|
||||
configSelect: "Konfiqurasiya seçin və ya saxlayın",
|
||||
configNew: "Yeni konfiqurasiya kimi saxla",
|
||||
configRename: "Konfiqurasiyanın adını dəyiş",
|
||||
configDuplicate: "Konfiqurasiyanı kopyala",
|
||||
configDelete: "Konfiqurasiyanı sil",
|
||||
configName: "Konfiqurasiya adı",
|
||||
configTableCount: "{count} cədvəl",
|
||||
defaultConfigName: "Müqayisə konfiqurasiyası",
|
||||
configSaved: "Konfiqurasiya saxlanıldı",
|
||||
title: "Verilənləri müqayisə et",
|
||||
selectTable: "Cədvəl seç",
|
||||
searchTable: "Cədvəlləri axtar...",
|
||||
|
||||
@@ -7656,6 +7656,15 @@ export default {
|
||||
noTables: "No tables available",
|
||||
},
|
||||
dataCompare: {
|
||||
configSelect: "Select or save a config",
|
||||
configNew: "Save as new config",
|
||||
configRename: "Rename config",
|
||||
configDuplicate: "Duplicate config",
|
||||
configDelete: "Delete config",
|
||||
configName: "Config name",
|
||||
configTableCount: "{count} tables",
|
||||
defaultConfigName: "Compare config",
|
||||
configSaved: "Config saved",
|
||||
manualTransactionHint: "Changes wait for Commit or Rollback. An error rolls back the transaction.",
|
||||
pendingTransaction: "Awaiting Commit or Rollback",
|
||||
rollbackBeforeClose: "There are uncommitted changes. Roll back and close?",
|
||||
|
||||
@@ -7002,6 +7002,15 @@ export default withEnglishFallback({
|
||||
noTables: "No hay tablas disponibles",
|
||||
},
|
||||
dataCompare: {
|
||||
configSelect: "Selecciona o guarda una configuración",
|
||||
configNew: "Guardar como configuración nueva",
|
||||
configRename: "Cambiar nombre de la configuración",
|
||||
configDuplicate: "Duplicar configuración",
|
||||
configDelete: "Eliminar configuración",
|
||||
configName: "Nombre de la configuración",
|
||||
configTableCount: "{count} tablas",
|
||||
defaultConfigName: "Configuración de comparación",
|
||||
configSaved: "Configuración guardada",
|
||||
manualTransactionHint: "Los cambios esperan a Confirmar o Revertir. Un error revierte la transacción.",
|
||||
pendingTransaction: "Esperando confirmación o reversión",
|
||||
rollbackBeforeClose: "Hay cambios sin confirmar. ¿Revertir y cerrar?",
|
||||
|
||||
@@ -7449,6 +7449,15 @@ export default withEnglishFallback({
|
||||
noTables: "Tidak ada tabel yang tersedia",
|
||||
},
|
||||
dataCompare: {
|
||||
configSelect: "Pilih atau simpan konfigurasi",
|
||||
configNew: "Simpan sebagai konfigurasi baru",
|
||||
configRename: "Ganti nama konfigurasi",
|
||||
configDuplicate: "Duplikat konfigurasi",
|
||||
configDelete: "Hapus konfigurasi",
|
||||
configName: "Nama konfigurasi",
|
||||
configTableCount: "{count} tabel",
|
||||
defaultConfigName: "Konfigurasi perbandingan",
|
||||
configSaved: "Konfigurasi disimpan",
|
||||
manualTransactionHint: "Perubahan menunggu Commit atau Rollback. Galat akan me-rollback transaksi.",
|
||||
pendingTransaction: "Menunggu Commit atau Rollback",
|
||||
rollbackBeforeClose: "Ada perubahan yang belum di-commit. Rollback dan tutup?",
|
||||
|
||||
@@ -6780,6 +6780,15 @@ export default withEnglishFallback({
|
||||
noTables: "Nessuna tabella disponibile",
|
||||
},
|
||||
dataCompare: {
|
||||
configSelect: "Seleziona o salva una configurazione",
|
||||
configNew: "Salva come nuova configurazione",
|
||||
configRename: "Rinomina configurazione",
|
||||
configDuplicate: "Duplica configurazione",
|
||||
configDelete: "Elimina configurazione",
|
||||
configName: "Nome configurazione",
|
||||
configTableCount: "{count} tabelle",
|
||||
defaultConfigName: "Configurazione confronto",
|
||||
configSaved: "Configurazione salvata",
|
||||
manualTransactionHint: "Le modifiche attendono Conferma o Annulla. Un errore annulla la transazione.",
|
||||
pendingTransaction: "In attesa di conferma o annullamento",
|
||||
rollbackBeforeClose: "Ci sono modifiche non confermate. Annullare e chiudere?",
|
||||
|
||||
@@ -6811,6 +6811,15 @@ export default withEnglishFallback({
|
||||
noTables: "利用可能なテーブルがありません",
|
||||
},
|
||||
dataCompare: {
|
||||
configSelect: "設定を選択または保存",
|
||||
configNew: "新しい設定として保存",
|
||||
configRename: "設定の名前を変更",
|
||||
configDuplicate: "設定を複製",
|
||||
configDelete: "設定を削除",
|
||||
configName: "設定名",
|
||||
configTableCount: "{count} テーブル",
|
||||
defaultConfigName: "データ比較設定",
|
||||
configSaved: "設定を保存しました",
|
||||
manualTransactionHint: "変更はコミットまたはロールバックを待ちます。エラー時はトランザクションをロールバックします。",
|
||||
pendingTransaction: "コミットまたはロールバック待ち",
|
||||
rollbackBeforeClose: "未コミットの変更があります。ロールバックして閉じますか?",
|
||||
|
||||
@@ -6576,6 +6576,15 @@ export default withEnglishFallback({
|
||||
},
|
||||
},
|
||||
dataCompare: {
|
||||
configSelect: "구성 선택 또는 저장",
|
||||
configNew: "새 구성으로 저장",
|
||||
configRename: "구성 이름 변경",
|
||||
configDuplicate: "구성 복제",
|
||||
configDelete: "구성 삭제",
|
||||
configName: "구성 이름",
|
||||
configTableCount: "테이블 {count}개",
|
||||
defaultConfigName: "데이터 비교 구성",
|
||||
configSaved: "구성이 저장되었습니다",
|
||||
manualTransactionHint: "변경 사항은 커밋 또는 롤백을 기다립니다. 오류가 발생하면 트랜잭션을 롤백합니다.",
|
||||
pendingTransaction: "커밋 또는 롤백 대기 중",
|
||||
rollbackBeforeClose: "커밋되지 않은 변경 사항이 있습니다. 롤백하고 닫을까요?",
|
||||
|
||||
@@ -6782,6 +6782,15 @@ export default withEnglishFallback({
|
||||
noTables: "Nenhuma tabela disponível",
|
||||
},
|
||||
dataCompare: {
|
||||
configSelect: "Selecione ou salve uma configuração",
|
||||
configNew: "Salvar como nova configuração",
|
||||
configRename: "Renomear configuração",
|
||||
configDuplicate: "Duplicar configuração",
|
||||
configDelete: "Excluir configuração",
|
||||
configName: "Nome da configuração",
|
||||
configTableCount: "{count} tabelas",
|
||||
defaultConfigName: "Configuração de comparação",
|
||||
configSaved: "Configuração salva",
|
||||
manualTransactionHint: "As alterações aguardam confirmação ou reversão. Um erro reverte a transação.",
|
||||
pendingTransaction: "Aguardando confirmação ou reversão",
|
||||
rollbackBeforeClose: "Há alterações não confirmadas. Reverter e fechar?",
|
||||
|
||||
@@ -7986,6 +7986,15 @@ export default withEnglishFallback({
|
||||
noTables: "Нет доступных таблиц",
|
||||
},
|
||||
dataCompare: {
|
||||
configSelect: "Выберите или сохраните конфигурацию",
|
||||
configNew: "Сохранить как новую конфигурацию",
|
||||
configRename: "Переименовать конфигурацию",
|
||||
configDuplicate: "Дублировать конфигурацию",
|
||||
configDelete: "Удалить конфигурацию",
|
||||
configName: "Имя конфигурации",
|
||||
configTableCount: "Таблиц: {count}",
|
||||
defaultConfigName: "Конфигурация сравнения",
|
||||
configSaved: "Конфигурация сохранена",
|
||||
manualTransactionHint: "Изменения ожидают фиксации или отката. При ошибке транзакция откатывается.",
|
||||
pendingTransaction: "Ожидание фиксации или отката",
|
||||
rollbackBeforeClose: "Есть незафиксированные изменения. Откатить и закрыть?",
|
||||
|
||||
@@ -6674,6 +6674,15 @@ export default withEnglishFallback({
|
||||
noTables: "Kullanılabilir tablo yok",
|
||||
},
|
||||
dataCompare: {
|
||||
configSelect: "Yapılandırma seçin veya kaydedin",
|
||||
configNew: "Yeni yapılandırma olarak kaydet",
|
||||
configRename: "Yapılandırmayı yeniden adlandır",
|
||||
configDuplicate: "Yapılandırmayı çoğalt",
|
||||
configDelete: "Yapılandırmayı sil",
|
||||
configName: "Yapılandırma adı",
|
||||
configTableCount: "{count} tablo",
|
||||
defaultConfigName: "Veri karşılaştırma yapılandırması",
|
||||
configSaved: "Yapılandırma kaydedildi",
|
||||
title: "Verileri Karşılaştır",
|
||||
selectTable: "Tablo seçin",
|
||||
searchTable: "Tablolarda ara...",
|
||||
|
||||
@@ -7642,6 +7642,15 @@ export default withEnglishFallback({
|
||||
noTables: "没有可用表",
|
||||
},
|
||||
dataCompare: {
|
||||
configSelect: "选择或保存配置",
|
||||
configNew: "保存为新配置",
|
||||
configRename: "重命名配置",
|
||||
configDuplicate: "复制配置",
|
||||
configDelete: "删除配置",
|
||||
configName: "配置名称",
|
||||
configTableCount: "{count} 张表",
|
||||
defaultConfigName: "数据比较配置",
|
||||
configSaved: "配置已保存",
|
||||
manualTransactionHint: "修改等待手动提交或回滚,出错时回滚事务。",
|
||||
pendingTransaction: "等待提交或回滚",
|
||||
rollbackBeforeClose: "尚有未提交的修改。回滚并关闭?",
|
||||
|
||||
@@ -6135,6 +6135,15 @@ export default withEnglishFallback({
|
||||
noTables: "沒有可用的資料表",
|
||||
},
|
||||
dataCompare: {
|
||||
configSelect: "選擇或儲存設定",
|
||||
configNew: "另存為新設定",
|
||||
configRename: "重新命名設定",
|
||||
configDuplicate: "複製設定",
|
||||
configDelete: "刪除設定",
|
||||
configName: "設定名稱",
|
||||
configTableCount: "{count} 張表",
|
||||
defaultConfigName: "資料比對設定",
|
||||
configSaved: "設定已儲存",
|
||||
manualTransactionHint: "修改等待手動提交或回滾,發生錯誤時回滾交易。",
|
||||
pendingTransaction: "等待提交或回滾",
|
||||
rollbackBeforeClose: "尚有未提交的修改。回滾並關閉?",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
Reference in New Issue
Block a user