feat(transfer): add rebuild structure and data mode
This commit is contained in:
1 parent
2b2d1f0d95
commit
538aeed99c
37 files changed
+6720
-404
No files matched your search
@@ -146,7 +146,7 @@ async function copyFullCode() {
|
||||
|
||||
<div class="py-4 min-w-0">
|
||||
<p class="text-sm text-muted-foreground mb-3">{{ message || t("dangerDialog.message") }}</p>
|
||||
<p v-if="detailsText" class="text-xs text-muted-foreground mb-3 whitespace-pre-line">{{ detailsText }}</p>
|
||||
<p v-if="detailsText" class="text-xs text-muted-foreground mb-3 whitespace-pre-line max-h-40 overflow-auto">{{ detailsText }}</p>
|
||||
<slot name="options" />
|
||||
<div v-if="code" data-testid="danger-code-container" class="min-w-0">
|
||||
<div data-testid="danger-code-actions" class="mb-1 flex items-center justify-end gap-0.5">
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, ref, shallowRef, watch } from "vue";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { buildTransferObjectSelections } from "./transferSelections";
|
||||
import { createTaskLoadTracker } from "./taskLoadTracker";
|
||||
import { confirmTransferWithProductionSafety, createTransferSubmission, rebuildUnavailableReason, resolveTransferStrategy, transferStrategyOptions, type TransferStrategy } from "./transferStrategy";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -14,11 +15,13 @@ import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect
|
||||
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { TransferContent, TransferMode, TransferObjectKind, TransferTableNameCase } from "@/lib/backend/api";
|
||||
import type { TransferContent, TransferObjectKind, TransferTableNameCase } from "@/lib/backend/api";
|
||||
import { crossFamilyTransferableKinds, isSameTransferFamily, transferObjectKindsForDatabase } from "@/lib/database/transferObjectKinds";
|
||||
import ObjectSelectionTree from "@/components/transfer/ObjectSelectionTree.vue";
|
||||
import TransferTaskTree from "@/components/transfer/TransferTaskTree.vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import type { TransferTask, TransferTaskConfig } from "@/types/database";
|
||||
import { isSchemaAware, supportsTransfer } from "@/lib/database/databaseCapabilities";
|
||||
@@ -37,6 +40,7 @@ const { t } = useI18n();
|
||||
const { startDataTransferTask } = useExportTracker();
|
||||
const { toast } = useToast();
|
||||
const taskStore = useTransferTaskStore();
|
||||
const productionSafetyStore = useProductionSafetyStore();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -155,17 +159,34 @@ const targetSchema = ref("");
|
||||
const pendingTargetSchemaPrefill = ref("");
|
||||
|
||||
// Options
|
||||
const transferMode = ref<TransferMode>("append");
|
||||
const selectedStrategy = ref<TransferStrategy>("append");
|
||||
const targetTableStrategy = computed<TransferStrategy>({
|
||||
get: () => (transferContent.value === "structureOnly" && selectedStrategy.value !== "rebuild" ? "append" : selectedStrategy.value),
|
||||
set: (value) => {
|
||||
selectedStrategy.value = value;
|
||||
},
|
||||
});
|
||||
const targetTableNameCase = ref<TransferTableNameCase>("preserve");
|
||||
const quoteTargetColumnNames = ref(true);
|
||||
const batchSize = ref(1000);
|
||||
const showRebuildConfirm = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const pendingTransferId = ref<string | null>(null);
|
||||
const showStartConfirm = ref(false);
|
||||
const confirmationRequest = shallowRef<api.TransferRequest | null>(null);
|
||||
const confirmationPreview = shallowRef<api.TransferOwnershipPreview | null>(null);
|
||||
let resolveTransferConfirmation: ((confirmed: boolean) => void) | undefined;
|
||||
const ownershipDialogOpen = ref(false);
|
||||
const ownershipMissingOwners = ref<string[]>([]);
|
||||
const ownershipTargetOwner = ref("");
|
||||
const pendingOwnershipRequest = ref<api.TransferRequest | null>(null);
|
||||
const pendingOwnershipRefresh = ref<{ shouldRefreshTargetTree: boolean } | null>(null);
|
||||
let resolveOwnershipConfirmation: ((policy: api.TransferOwnershipPolicy | null) => void) | undefined;
|
||||
const transferSubmission = createTransferSubmission({
|
||||
ensureWritable: (request) => ensureReadOnlyWriteAccess({ connection: store.getConfig(request.targetConnectionId), source: t("readOnlyUnlock.sourceTransfer"), treatAsMutation: true }),
|
||||
preview: api.previewTransferOwnership,
|
||||
confirmOwnership: requestOwnershipConfirmation,
|
||||
confirm: requestTransferConfirmation,
|
||||
execute: (request) => runTransfer(request, request.content !== "dataOnly"),
|
||||
});
|
||||
|
||||
// Saved-task state: the form mirrors the active task; a canonical JSON
|
||||
// snapshot taken at load/save time drives the unsaved-changes check.
|
||||
@@ -186,6 +207,13 @@ function isMongoConnection(id: string): boolean {
|
||||
|
||||
const showTargetColumnQuoteOption = computed(() => ["gaussdb", "opengauss"].includes(connectionType(targetConnectionId.value) ?? ""));
|
||||
|
||||
const rebuildDisabledReason = computed(() => rebuildUnavailableReason(transferContent.value, connectionType(targetConnectionId.value)));
|
||||
const rebuildDisabledHint = computed(() => {
|
||||
if (rebuildDisabledReason.value === "dataOnly") return t("transfer.rebuildDataOnlyDisabled");
|
||||
if (rebuildDisabledReason.value === "unsupported") return t("transfer.rebuildUnsupportedDisabled");
|
||||
return "";
|
||||
});
|
||||
|
||||
function isCatalogCapable(id: string): boolean {
|
||||
const config = store.getConfig(id);
|
||||
return isDorisFamilyCatalogCapable(config?.db_type, config?.driver_profile);
|
||||
@@ -225,6 +253,7 @@ const canStart = computed(() => {
|
||||
(sourceCatalogs.value.length <= 1 || !!sourceCatalog.value) &&
|
||||
(targetCatalogs.value.length <= 1 || !!targetCatalog.value) &&
|
||||
(selectedTables.value.size > 0 || Object.values(selectedObjects.value).some((names) => names.size > 0)) &&
|
||||
(targetTableStrategy.value !== "rebuild" || !rebuildDisabledReason.value) &&
|
||||
!sameSourceAndTarget
|
||||
);
|
||||
});
|
||||
@@ -620,6 +649,7 @@ watch(
|
||||
|
||||
function resetState(cancelTaskLoad = true) {
|
||||
if (cancelTaskLoad) taskLoadTracker.cancel();
|
||||
cancelPendingTransfer();
|
||||
sourceConnectionId.value = "";
|
||||
sourceCatalog.value = "";
|
||||
sourceCatalogs.value = [];
|
||||
@@ -642,17 +672,10 @@ function resetState(cancelTaskLoad = true) {
|
||||
targetSchema.value = "";
|
||||
pendingTargetSchemaPrefill.value = "";
|
||||
transferContent.value = "structureAndData";
|
||||
transferMode.value = "append";
|
||||
selectedStrategy.value = "append";
|
||||
targetTableNameCase.value = "preserve";
|
||||
quoteTargetColumnNames.value = true;
|
||||
batchSize.value = 1000;
|
||||
isSubmitting.value = false;
|
||||
showStartConfirm.value = false;
|
||||
ownershipDialogOpen.value = false;
|
||||
ownershipMissingOwners.value = [];
|
||||
ownershipTargetOwner.value = "";
|
||||
pendingOwnershipRequest.value = null;
|
||||
pendingOwnershipRefresh.value = null;
|
||||
pendingSelectedObjectsPrefill.value = null;
|
||||
activeTaskId.value = null;
|
||||
savedConfigSnapshot.value = "";
|
||||
@@ -740,11 +763,8 @@ function swapSourceAndTarget() {
|
||||
void loadObjects();
|
||||
}
|
||||
|
||||
async function startTransfer() {
|
||||
async function requestStartTransfer() {
|
||||
if (!canStart.value || isSubmitting.value) return;
|
||||
if (!(await ensureReadOnlyWriteAccess({ connection: store.getConfig(targetConnectionId.value), source: t("readOnlyUnlock.sourceTransfer"), treatAsMutation: true }))) {
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
|
||||
const effectiveSourceSchema = sourceSchema.value || sourceDatabaseName.value;
|
||||
@@ -752,8 +772,6 @@ async function startTransfer() {
|
||||
const sourceDatabase = sourceDatabaseName.value;
|
||||
const targetConnection = targetConnectionId.value;
|
||||
const targetDatabase = targetDatabaseName.value;
|
||||
const shouldRefreshTargetTree = transferContent.value !== "dataOnly";
|
||||
|
||||
const request: api.TransferRequest = {
|
||||
transferId: uuid(),
|
||||
sourceConnectionId: sourceConnectionId.value,
|
||||
@@ -768,34 +786,25 @@ async function startTransfer() {
|
||||
createTable: transferContent.value !== "dataOnly",
|
||||
content: transferContent.value,
|
||||
objects: buildTransferObjectSelections(selectedObjects.value, treeDisabledGroups.value),
|
||||
mode: transferMode.value,
|
||||
...transferStrategyOptions(targetTableStrategy.value),
|
||||
targetTableNameCase: targetTableNameCase.value,
|
||||
quoteTargetColumnNames: quoteTargetColumnNames.value,
|
||||
ownershipPolicy: "preserve",
|
||||
batchSize: batchSize.value,
|
||||
dropTargetConfirmed: false,
|
||||
};
|
||||
|
||||
if (transferContent.value !== "dataOnly") {
|
||||
try {
|
||||
const preview = await api.previewTransferOwnership(request);
|
||||
if (preview.missingOwners.length > 0) {
|
||||
ownershipMissingOwners.value = preview.missingOwners;
|
||||
ownershipTargetOwner.value = preview.targetOwner;
|
||||
pendingOwnershipRequest.value = request;
|
||||
pendingOwnershipRefresh.value = {
|
||||
shouldRefreshTargetTree,
|
||||
};
|
||||
ownershipDialogOpen.value = true;
|
||||
isSubmitting.value = false;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
pendingTransferId.value = request.transferId;
|
||||
try {
|
||||
await transferSubmission.start(request);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast(message === "TRANSFER_REBUILD_PREVIEW_UNAVAILABLE" ? t("transfer.rebuildPreviewUnavailable") : t("transfer.previewFailed", { message }), 5000);
|
||||
} finally {
|
||||
if (pendingTransferId.value === request.transferId) {
|
||||
pendingTransferId.value = null;
|
||||
isSubmitting.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
runTransfer(request, shouldRefreshTargetTree);
|
||||
}
|
||||
|
||||
function runTransfer(request: api.TransferRequest, shouldRefreshTargetTree: boolean) {
|
||||
@@ -812,19 +821,22 @@ function runTransfer(request: api.TransferRequest, shouldRefreshTargetTree: bool
|
||||
resetState();
|
||||
}
|
||||
|
||||
function requestOwnershipConfirmation(preview: api.TransferOwnershipPreview): Promise<api.TransferOwnershipPolicy | null> {
|
||||
ownershipMissingOwners.value = preview.missingOwners;
|
||||
ownershipTargetOwner.value = preview.targetOwner;
|
||||
ownershipDialogOpen.value = true;
|
||||
return new Promise((resolve) => {
|
||||
resolveOwnershipConfirmation = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveOwnershipDecision(policy: api.TransferOwnershipPolicy | null) {
|
||||
const request = pendingOwnershipRequest.value;
|
||||
const refresh = pendingOwnershipRefresh.value;
|
||||
pendingOwnershipRequest.value = null;
|
||||
pendingOwnershipRefresh.value = null;
|
||||
const resolve = resolveOwnershipConfirmation;
|
||||
resolveOwnershipConfirmation = undefined;
|
||||
ownershipDialogOpen.value = false;
|
||||
ownershipMissingOwners.value = [];
|
||||
ownershipTargetOwner.value = "";
|
||||
if (!policy || !request || !refresh) {
|
||||
isSubmitting.value = false;
|
||||
return;
|
||||
}
|
||||
runTransfer({ ...request, ownershipPolicy: policy }, refresh.shouldRefreshTargetTree);
|
||||
resolve?.(policy);
|
||||
}
|
||||
|
||||
function getConnectionName(id: string) {
|
||||
@@ -854,10 +866,11 @@ function currentConfig(): TransferTaskConfig {
|
||||
targetSchema: targetSchema.value || undefined,
|
||||
objects,
|
||||
content: transferContent.value,
|
||||
mode: transferMode.value,
|
||||
...transferStrategyOptions(targetTableStrategy.value),
|
||||
targetTableNameCase: targetTableNameCase.value,
|
||||
quoteTargetColumnNames: quoteTargetColumnNames.value,
|
||||
batchSize: batchSize.value,
|
||||
dropTargetConfirmed: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -875,6 +888,27 @@ const isConfigDirty = computed(() => !!activeTaskId.value && configSnapshot(curr
|
||||
const needsDiscardConfirm = computed(() => isConfigDirty.value || (!activeTaskId.value && formHasContent.value));
|
||||
const canSaveConfig = computed(() => !!sourceConnectionId.value && isTransferDatabaseSelected(sourceDatabase.value) && !!targetConnectionId.value && isTransferDatabaseSelected(targetDatabase.value));
|
||||
|
||||
// Invalidate before asynchronous callbacks can publish a preview for an old form,
|
||||
// including a task switch whose new values happen to equal the previous task.
|
||||
watch(() => configSnapshot(currentConfig()), cancelPendingTransfer, { flush: "sync" });
|
||||
watch(activeTaskId, cancelPendingTransfer, { flush: "sync" });
|
||||
watch(
|
||||
open,
|
||||
(isOpen) => {
|
||||
if (!isOpen) cancelPendingTransfer();
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
watch(
|
||||
() => {
|
||||
const connection = store.getConfig(targetConnectionId.value);
|
||||
return JSON.stringify([connection?.read_only, connection?.is_production, connection?.production_databases]);
|
||||
},
|
||||
cancelPendingTransfer,
|
||||
{ flush: "sync" },
|
||||
);
|
||||
onBeforeUnmount(cancelPendingTransfer);
|
||||
|
||||
/** Applies a saved task to the form, loading catalogs/databases/schemas/objects with explicit awaits. */
|
||||
async function loadTaskIntoForm(task: TransferTask) {
|
||||
const taskLoadToken = taskLoadTracker.begin(task.id);
|
||||
@@ -883,7 +917,7 @@ async function loadTaskIntoForm(task: TransferTask) {
|
||||
resetState(false);
|
||||
activeTaskId.value = task.id;
|
||||
transferContent.value = config.content;
|
||||
transferMode.value = config.mode;
|
||||
selectedStrategy.value = resolveTransferStrategy(config);
|
||||
targetTableNameCase.value = config.targetTableNameCase;
|
||||
quoteTargetColumnNames.value = config.quoteTargetColumnNames;
|
||||
batchSize.value = config.batchSize;
|
||||
@@ -1000,26 +1034,93 @@ function onSelectedTaskIdUpdate(id: string | null) {
|
||||
activeTaskId.value = id;
|
||||
}
|
||||
|
||||
// ---- start confirmation (guards the only run entry: the footer button) ----
|
||||
// ---- confirmation for the immutable, previewed transfer request ----
|
||||
|
||||
/** Number of objects currently selected across all kinds. */
|
||||
const selectedObjectCount = computed(() => Object.values(selectedObjects.value).reduce((total, names) => total + (names?.size ?? 0), 0));
|
||||
|
||||
const startConfirmSource = computed(() => `${getConnectionName(sourceConnectionId.value)}.${databaseOptionLabel(sourceConnectionId.value, sourceDatabase.value)}`);
|
||||
const startConfirmTarget = computed(() => `${getConnectionName(targetConnectionId.value)}.${databaseOptionLabel(targetConnectionId.value, targetDatabase.value)}`);
|
||||
|
||||
/** Opens the confirmation dialog before starting a transfer. */
|
||||
function requestStartTransfer() {
|
||||
if (!canStart.value || isSubmitting.value) return;
|
||||
showStartConfirm.value = true;
|
||||
function transferStrategyLabel(request: api.TransferRequest): string {
|
||||
if (request.content === "structureOnly") return t(request.dropTargetBeforeCreate ? "transfer.modeRebuildStructure" : "transfer.modeKeepExisting");
|
||||
const strategy = resolveTransferStrategy(request);
|
||||
const keys = { append: "modeAppend", overwrite: "modeOverwrite", upsert: "modeUpsert", rebuild: "modeRebuild" };
|
||||
return t(`transfer.${keys[strategy]}`);
|
||||
}
|
||||
|
||||
/** Confirmed: close the prompt and run the normal start flow. */
|
||||
function confirmStartTransfer() {
|
||||
const confirmationSummary = computed(() => {
|
||||
const request = confirmationRequest.value;
|
||||
if (!request) return "";
|
||||
const source = `${getConnectionName(request.sourceConnectionId)}.${request.sourceDatabase}.${request.sourceSchema}`;
|
||||
const target = `${getConnectionName(request.targetConnectionId)}.${request.targetDatabase}.${request.targetSchema}`;
|
||||
const count = request.objects.reduce((total, selection) => total + selection.names.length, 0);
|
||||
return t("transfer.startConfirmMessage", { source, target, count });
|
||||
});
|
||||
|
||||
const confirmationStrategy = computed(() => (confirmationRequest.value ? transferStrategyLabel(confirmationRequest.value) : ""));
|
||||
const rebuildConfirmationDetails = computed(() => {
|
||||
const rebuild = confirmationPreview.value?.rebuild;
|
||||
if (!rebuild) return "";
|
||||
return [confirmationSummary.value, t("transfer.rebuildSummary", { count: rebuild.tables.length }), ...rebuild.warnings].filter(Boolean).join("\n");
|
||||
});
|
||||
|
||||
function requestTransferConfirmation(request: api.TransferRequest, preview: api.TransferOwnershipPreview): Promise<boolean> {
|
||||
confirmationRequest.value = request;
|
||||
confirmationPreview.value = preview;
|
||||
const reviewText = preview.rebuild
|
||||
? [confirmationStrategy.value, rebuildConfirmationDetails.value, preview.rebuild.sql].filter(Boolean).join("\n\n")
|
||||
: [confirmationSummary.value, `${t("transfer.targetTableHandling")}: ${confirmationStrategy.value}`, ...request.objects.map((selection) => selection.names.join(", "))].join("\n");
|
||||
return confirmTransferWithProductionSafety({
|
||||
request,
|
||||
connection: store.getConfig(request.targetConnectionId),
|
||||
reviewText,
|
||||
source: t("transfer.title"),
|
||||
confirm: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveTransferConfirmation = resolve;
|
||||
if (request.dropTargetBeforeCreate) showRebuildConfirm.value = true;
|
||||
else showStartConfirm.value = true;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function resolveStartDecision(confirmed: boolean) {
|
||||
const resolve = resolveTransferConfirmation;
|
||||
resolveTransferConfirmation = undefined;
|
||||
showStartConfirm.value = false;
|
||||
void startTransfer();
|
||||
showRebuildConfirm.value = false;
|
||||
resolve?.(confirmed);
|
||||
}
|
||||
|
||||
function cancelPendingTransfer() {
|
||||
transferSubmission.cancel();
|
||||
const scopeId = pendingTransferId.value;
|
||||
pendingTransferId.value = null;
|
||||
if (scopeId) productionSafetyStore.cancelScope(scopeId);
|
||||
resolveStartDecision(false);
|
||||
resolveOwnershipDecision(null);
|
||||
confirmationRequest.value = null;
|
||||
confirmationPreview.value = null;
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
showStartConfirm,
|
||||
(isOpen) => {
|
||||
if (!isOpen) resolveStartDecision(false);
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
watch(
|
||||
showRebuildConfirm,
|
||||
(isOpen) => {
|
||||
if (!isOpen) resolveStartDecision(false);
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
watch(
|
||||
ownershipDialogOpen,
|
||||
(isOpen) => {
|
||||
if (!isOpen) resolveOwnershipDecision(null);
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
|
||||
/** Saves the form into the active task, or creates a new one and starts its rename. */
|
||||
async function saveConfigTask() {
|
||||
if (!canSaveConfig.value) return;
|
||||
@@ -1245,19 +1346,21 @@ async function saveConfigTask() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="transferContent !== 'structureOnly'" class="flex items-center gap-3">
|
||||
<Label class="text-xs shrink-0">{{ t("transfer.dataWriteMode") }}</Label>
|
||||
<Select v-model="transferMode">
|
||||
<div class="flex items-center gap-3">
|
||||
<Label class="text-xs shrink-0">{{ t("transfer.targetTableHandling") }}</Label>
|
||||
<Select v-model="targetTableStrategy">
|
||||
<SelectTrigger class="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="append">{{ t("transfer.modeAppend") }}</SelectItem>
|
||||
<SelectItem value="overwrite">{{ t("transfer.modeOverwrite") }}</SelectItem>
|
||||
<SelectItem value="upsert">{{ t("transfer.modeUpsert") }}</SelectItem>
|
||||
<SelectItem value="append">{{ t(transferContent === "structureOnly" ? "transfer.modeKeepExisting" : "transfer.modeAppend") }}</SelectItem>
|
||||
<SelectItem v-if="transferContent !== 'structureOnly'" value="overwrite">{{ t("transfer.modeOverwrite") }}</SelectItem>
|
||||
<SelectItem v-if="transferContent !== 'structureOnly'" value="upsert">{{ t("transfer.modeUpsert") }}</SelectItem>
|
||||
<SelectItem value="rebuild" :disabled="!!rebuildDisabledReason">{{ t(transferContent === "structureOnly" ? "transfer.modeRebuildStructure" : "transfer.modeRebuild") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p v-if="rebuildDisabledHint" class="text-xs text-muted-foreground">{{ rebuildDisabledHint }}</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<Label class="text-xs shrink-0">{{ t("transfer.targetTableNameCase") }}</Label>
|
||||
<Select v-model="targetTableNameCase">
|
||||
@@ -1324,20 +1427,32 @@ async function saveConfigTask() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("transfer.startConfirmTitle") }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ t("transfer.startConfirmMessage", { source: startConfirmSource, target: startConfirmTarget, count: selectedObjectCount }) }}
|
||||
{{ confirmationSummary }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p class="text-xs text-muted-foreground">{{ t("transfer.targetTableHandling") }}: {{ confirmationStrategy }}</p>
|
||||
<DialogFooter class="gap-2">
|
||||
<Button variant="outline" size="sm" @click="showStartConfirm = false">
|
||||
<Button variant="outline" size="sm" @click="resolveStartDecision(false)">
|
||||
{{ t("transfer.cancel") }}
|
||||
</Button>
|
||||
<Button size="sm" @click="confirmStartTransfer">
|
||||
<Button size="sm" @click="resolveStartDecision(true)">
|
||||
{{ t("transfer.start") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showRebuildConfirm"
|
||||
:sql="confirmationPreview?.rebuild?.sql"
|
||||
:title="confirmationStrategy"
|
||||
:message="t('transfer.rebuildDanger')"
|
||||
:details-text="rebuildConfirmationDetails"
|
||||
:confirm-label="t('transfer.start')"
|
||||
:close-on-confirm="false"
|
||||
@confirm="resolveStartDecision(true)"
|
||||
/>
|
||||
|
||||
<Dialog v-model:open="ownershipDialogOpen">
|
||||
<DialogContent class="sm:max-w-[520px]" @interact-outside.prevent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TransferOwnershipPreview, TransferRequest } from "@/lib/backend/api";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
import { confirmTransferWithProductionSafety, createTransferSubmission, rebuildUnavailableReason, resolveTransferStrategy, transferStrategyOptions } from "../transferStrategy";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function request(overrides: Partial<TransferRequest> = {}): TransferRequest {
|
||||
return {
|
||||
transferId: "transfer-1",
|
||||
sourceConnectionId: "source",
|
||||
sourceDatabase: "app",
|
||||
sourceSchema: "public",
|
||||
targetConnectionId: "target",
|
||||
targetDatabase: "warehouse",
|
||||
targetSchema: "reporting",
|
||||
tables: ["Orders"],
|
||||
objects: [{ objectType: "TABLE", names: ["Orders"] }],
|
||||
createTable: true,
|
||||
content: "structureAndData",
|
||||
mode: "append",
|
||||
targetTableNameCase: "lower",
|
||||
quoteTargetColumnNames: true,
|
||||
ownershipPolicy: "preserve",
|
||||
batchSize: 1000,
|
||||
dropTargetBeforeCreate: true,
|
||||
dropTargetConfirmed: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function preview(overrides: Partial<TransferOwnershipPreview> = {}): TransferOwnershipPreview {
|
||||
return {
|
||||
missingOwners: [],
|
||||
targetOwner: "target_user",
|
||||
rebuild: {
|
||||
sql: 'ALTER TABLE "reporting"."orders" RENAME TO "orders__dbx_bak_123";\nCREATE TABLE "reporting"."orders" ("id" INTEGER);',
|
||||
tables: [{ sourceTable: "Orders", targetTable: '"reporting"."orders"', backupTable: '"reporting"."orders__dbx_bak_123"' }],
|
||||
warnings: ["Target-only metadata will be replaced"],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("transfer strategies", () => {
|
||||
it.each(["append", "overwrite", "upsert"] as const)("loads legacy %s plus rebuild as rebuild", (mode) => {
|
||||
expect(resolveTransferStrategy({ mode, dropTargetBeforeCreate: true })).toBe("rebuild");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["append", { mode: "append", dropTargetBeforeCreate: false }],
|
||||
["overwrite", { mode: "overwrite", dropTargetBeforeCreate: false }],
|
||||
["upsert", { mode: "upsert", dropTargetBeforeCreate: false }],
|
||||
["rebuild", { mode: "append", dropTargetBeforeCreate: true }],
|
||||
] as const)("maps %s to one backend strategy", (strategy, options) => {
|
||||
expect(transferStrategyOptions(strategy)).toEqual(options);
|
||||
expect(resolveTransferStrategy(options)).toBe(strategy);
|
||||
});
|
||||
|
||||
it("defaults legacy tasks without strategy fields to append", () => {
|
||||
expect(resolveTransferStrategy({})).toBe("append");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["dataOnly", "postgres", "dataOnly"],
|
||||
["structureAndData", "mongodb", "unsupported"],
|
||||
["structureOnly", "postgres", undefined],
|
||||
["structureAndData", "sqlite", undefined],
|
||||
["structureAndData", undefined, "unsupported"],
|
||||
] as const)("explains rebuild availability for %s and %s", (content, type, reason) => {
|
||||
expect(rebuildUnavailableReason(content, type)).toBe(reason);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transfer submission", () => {
|
||||
it("reviews backend SQL and executes the frozen request only after confirmation", async () => {
|
||||
const decision = deferred<boolean>();
|
||||
const reviewed: Array<{ request: TransferRequest; preview: TransferOwnershipPreview }> = [];
|
||||
const executions: TransferRequest[] = [];
|
||||
const previews: TransferRequest[] = [];
|
||||
const submission = createTransferSubmission({
|
||||
preview: async (value) => {
|
||||
previews.push(value);
|
||||
return preview();
|
||||
},
|
||||
confirmOwnership: async () => "preserve",
|
||||
confirm: (value, plan) => {
|
||||
reviewed.push({ request: value, preview: plan });
|
||||
return decision.promise;
|
||||
},
|
||||
execute: (value) => {
|
||||
executions.push(value);
|
||||
},
|
||||
});
|
||||
const input = request({ mode: "upsert", dropTargetConfirmed: true });
|
||||
|
||||
const pending = submission.start(input);
|
||||
input.targetDatabase = "changed";
|
||||
input.tables.push("Unreviewed");
|
||||
input.objects[0]!.names.push("Unreviewed");
|
||||
await vi.waitFor(() => expect(reviewed).toHaveLength(1));
|
||||
|
||||
expect(previews[0]).toMatchObject({ transferId: "transfer-1", targetDatabase: "warehouse", mode: "append", tables: ["Orders"], objects: [{ objectType: "TABLE", names: ["Orders"] }], dropTargetConfirmed: false });
|
||||
expect(() => previews[0]!.tables.push("Mutated")).toThrow();
|
||||
expect(reviewed[0]?.preview.rebuild?.sql).toBe('ALTER TABLE "reporting"."orders" RENAME TO "orders__dbx_bak_123";\nCREATE TABLE "reporting"."orders" ("id" INTEGER);');
|
||||
expect(executions).toEqual([]);
|
||||
|
||||
decision.resolve(true);
|
||||
await expect(pending).resolves.toBe(true);
|
||||
expect(executions).toEqual([{ ...previews[0], dropTargetConfirmed: true }]);
|
||||
expect(previews[0]?.dropTargetConfirmed).toBe(false);
|
||||
});
|
||||
|
||||
it("discards a preview after the form or dialog invalidates its submission", async () => {
|
||||
const backend = deferred<TransferOwnershipPreview>();
|
||||
const reviewed: TransferRequest[] = [];
|
||||
const executions: TransferRequest[] = [];
|
||||
const submission = createTransferSubmission({
|
||||
preview: () => backend.promise,
|
||||
confirmOwnership: async () => "preserve",
|
||||
confirm: async (value) => {
|
||||
reviewed.push(value);
|
||||
return true;
|
||||
},
|
||||
execute: (value) => {
|
||||
executions.push(value);
|
||||
},
|
||||
});
|
||||
|
||||
const pending = submission.start(request());
|
||||
submission.cancel();
|
||||
backend.resolve(preview({ missingOwners: ["old_owner"] }));
|
||||
|
||||
await expect(pending).resolves.toBe(false);
|
||||
expect(reviewed).toEqual([]);
|
||||
expect(executions).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not execute an older confirmation after a newer transfer starts", async () => {
|
||||
const decision = deferred<boolean>();
|
||||
const executions: TransferRequest[] = [];
|
||||
let confirmationCount = 0;
|
||||
const submission = createTransferSubmission({
|
||||
preview: async () => preview(),
|
||||
confirmOwnership: async () => "preserve",
|
||||
confirm: async (value) => {
|
||||
confirmationCount++;
|
||||
return value.transferId === "old" ? decision.promise : true;
|
||||
},
|
||||
execute: (value) => {
|
||||
executions.push(value);
|
||||
},
|
||||
});
|
||||
|
||||
const old = submission.start(request({ transferId: "old" }));
|
||||
await vi.waitFor(() => expect(confirmationCount).toBe(1));
|
||||
await expect(submission.start(request({ transferId: "new" }))).resolves.toBe(true);
|
||||
decision.resolve(true);
|
||||
|
||||
await expect(old).resolves.toBe(false);
|
||||
expect(executions.map((value) => value.transferId)).toEqual(["new"]);
|
||||
});
|
||||
|
||||
it("re-previews a changed ownership policy with the same transfer ID before final confirmation", async () => {
|
||||
const previews: TransferRequest[] = [];
|
||||
const reviewed: TransferOwnershipPreview[] = [];
|
||||
const executions: TransferRequest[] = [];
|
||||
const submission = createTransferSubmission({
|
||||
preview: async (value) => {
|
||||
previews.push(value);
|
||||
return preview({ missingOwners: ["old_owner"], rebuild: { ...preview().rebuild!, sql: value.ownershipPolicy === "reassignMissing" ? "REASSIGNED PLAN" : "ORIGINAL PLAN" } });
|
||||
},
|
||||
confirmOwnership: async () => "reassignMissing",
|
||||
confirm: async (_value, plan) => {
|
||||
reviewed.push(plan);
|
||||
return true;
|
||||
},
|
||||
execute: (value) => {
|
||||
executions.push(value);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(submission.start(request())).resolves.toBe(true);
|
||||
|
||||
expect(previews.map((value) => [value.transferId, value.ownershipPolicy, value.dropTargetConfirmed])).toEqual([
|
||||
["transfer-1", "preserve", false],
|
||||
["transfer-1", "reassignMissing", false],
|
||||
]);
|
||||
expect(reviewed.map((value) => value.rebuild?.sql)).toEqual(["REASSIGNED PLAN"]);
|
||||
expect(executions[0]?.ownershipPolicy).toBe("reassignMissing");
|
||||
});
|
||||
|
||||
it("refuses rebuild execution when the backend does not provide a rebuild plan", async () => {
|
||||
const executions: TransferRequest[] = [];
|
||||
const submission = createTransferSubmission({
|
||||
preview: async () => preview({ rebuild: undefined }),
|
||||
confirmOwnership: async () => "preserve",
|
||||
confirm: async () => true,
|
||||
execute: (value) => {
|
||||
executions.push(value);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(submission.start(request())).rejects.toThrow("TRANSFER_REBUILD_PREVIEW_UNAVAILABLE");
|
||||
expect(executions).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not surface errors from a cancelled preview", async () => {
|
||||
const backend = deferred<TransferOwnershipPreview>();
|
||||
const submission = createTransferSubmission({ preview: () => backend.promise, confirmOwnership: async () => null, confirm: async () => false, execute: () => undefined });
|
||||
const pending = submission.start(request());
|
||||
submission.cancel();
|
||||
backend.reject(new Error("old connection failed"));
|
||||
|
||||
await expect(pending).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it.each(["append", "overwrite", "upsert"] as const)("keeps ordinary %s data-only requests free of rebuild authorization", async (mode) => {
|
||||
const executions: TransferRequest[] = [];
|
||||
const submission = createTransferSubmission({
|
||||
preview: async () => {
|
||||
throw new Error("Data-only must not load DDL preview");
|
||||
},
|
||||
confirmOwnership: async () => null,
|
||||
confirm: async () => true,
|
||||
execute: (value) => {
|
||||
executions.push(value);
|
||||
},
|
||||
});
|
||||
|
||||
await submission.start(request({ content: "dataOnly", createTable: false, mode, dropTargetBeforeCreate: false, dropTargetConfirmed: true }));
|
||||
|
||||
expect(executions[0]).toMatchObject({ mode, content: "dataOnly", createTable: false, dropTargetBeforeCreate: false, dropTargetConfirmed: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("transfer production confirmation", () => {
|
||||
beforeEach(() => setActivePinia(createPinia()));
|
||||
|
||||
function connection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
|
||||
return { id: "target", name: "Warehouse", db_type: "postgres", host: "localhost", port: 5432, username: "target_user", password: "", ...overrides };
|
||||
}
|
||||
|
||||
it.each([{ is_production: true }, { production_databases: ["warehouse"] }])("uses one shared confirmation for production scope %j", async (scope) => {
|
||||
let ordinaryConfirmations = 0;
|
||||
const pending = confirmTransferWithProductionSafety({
|
||||
request: request(),
|
||||
connection: connection(scope),
|
||||
reviewText: preview().rebuild!.sql,
|
||||
source: "Data Transfer",
|
||||
confirm: async () => {
|
||||
ordinaryConfirmations++;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const store = useProductionSafetyStore();
|
||||
expect(store.pending).toMatchObject({ scopeId: "transfer-1", database: "warehouse", connectionName: "Warehouse", sql: preview().rebuild!.sql });
|
||||
expect(ordinaryConfirmations).toBe(0);
|
||||
|
||||
store.confirm();
|
||||
await expect(pending).resolves.toBe(true);
|
||||
expect(store.pending).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cancels a pending production confirmation with the transfer scope", async () => {
|
||||
const pending = confirmTransferWithProductionSafety({ request: request(), connection: connection({ production_databases: ["warehouse"] }), reviewText: "PLAN", confirm: async () => true });
|
||||
|
||||
useProductionSafetyStore().cancelScope("transfer-1");
|
||||
|
||||
await expect(pending).resolves.toBe(false);
|
||||
expect(useProductionSafetyStore().pending).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the transfer confirmation for a non-production target", async () => {
|
||||
let ordinaryConfirmations = 0;
|
||||
const result = await confirmTransferWithProductionSafety({
|
||||
request: request(),
|
||||
connection: connection(),
|
||||
reviewText: "PLAN",
|
||||
confirm: async () => {
|
||||
ordinaryConfirmations++;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(ordinaryConfirmations).toBe(1);
|
||||
expect(useProductionSafetyStore().pending).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { TransferContent, TransferMode, TransferOwnershipPolicy, TransferOwnershipPreview, TransferRequest } from "@/lib/backend/api";
|
||||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
|
||||
export type TransferStrategy = TransferMode | "rebuild";
|
||||
|
||||
export function resolveTransferStrategy(options: { mode?: TransferMode; dropTargetBeforeCreate?: boolean }): TransferStrategy {
|
||||
return options.dropTargetBeforeCreate ? "rebuild" : (options.mode ?? "append");
|
||||
}
|
||||
|
||||
export function transferStrategyOptions(strategy: TransferStrategy): Pick<TransferRequest, "mode" | "dropTargetBeforeCreate"> {
|
||||
return { mode: strategy === "rebuild" ? "append" : strategy, dropTargetBeforeCreate: strategy === "rebuild" };
|
||||
}
|
||||
|
||||
const REBUILD_TARGET_TYPES = new Set<DatabaseType>(["mysql", "postgres", "sqlserver", "kingbase", "gaussdb", "opengauss", "kwdb", "goldendb", "sqlite", "duckdb", "cloudflare-d1"]);
|
||||
|
||||
export function rebuildUnavailableReason(content: TransferContent, targetType: DatabaseType | undefined): "dataOnly" | "unsupported" | undefined {
|
||||
if (content === "dataOnly") return "dataOnly";
|
||||
return targetType && REBUILD_TARGET_TYPES.has(targetType) ? undefined : "unsupported";
|
||||
}
|
||||
|
||||
/** Snapshot the complete selection before any asynchronous preview or confirmation. */
|
||||
function freezeTransferRequest(request: TransferRequest): TransferRequest {
|
||||
const snapshot = {
|
||||
...request,
|
||||
...transferStrategyOptions(resolveTransferStrategy(request)),
|
||||
tables: [...request.tables],
|
||||
objects: request.objects.map((selection) => ({ ...selection, names: [...selection.names] })),
|
||||
dropTargetConfirmed: false,
|
||||
};
|
||||
Object.freeze(snapshot.tables);
|
||||
for (const selection of snapshot.objects) {
|
||||
Object.freeze(selection.names);
|
||||
Object.freeze(selection);
|
||||
}
|
||||
Object.freeze(snapshot.objects);
|
||||
return Object.freeze(snapshot);
|
||||
}
|
||||
|
||||
interface TransferSubmissionOptions {
|
||||
ensureWritable?: (request: TransferRequest) => Promise<boolean>;
|
||||
preview: (request: TransferRequest) => Promise<TransferOwnershipPreview>;
|
||||
confirmOwnership: (preview: TransferOwnershipPreview) => Promise<TransferOwnershipPolicy | null>;
|
||||
confirm: (request: TransferRequest, preview: TransferOwnershipPreview) => Promise<boolean>;
|
||||
execute: (request: TransferRequest) => void;
|
||||
}
|
||||
|
||||
/** Invalidating a submission prevents every later await from reopening prompts or starting it. */
|
||||
export function createTransferSubmission(options: TransferSubmissionOptions) {
|
||||
let generation = 0;
|
||||
return {
|
||||
cancel() {
|
||||
generation += 1;
|
||||
},
|
||||
async start(input: TransferRequest): Promise<boolean> {
|
||||
const token = ++generation;
|
||||
const isCurrent = () => token === generation;
|
||||
let request = freezeTransferRequest(input);
|
||||
try {
|
||||
if (options.ensureWritable && (!(await options.ensureWritable(request)) || !isCurrent())) return false;
|
||||
let preview: TransferOwnershipPreview = request.content === "dataOnly" ? { missingOwners: [], targetOwner: "" } : await options.preview(request);
|
||||
if (!isCurrent()) return false;
|
||||
if (preview.missingOwners.length > 0) {
|
||||
const policy = await options.confirmOwnership(preview);
|
||||
if (!policy || !isCurrent()) return false;
|
||||
if (policy !== request.ownershipPolicy) {
|
||||
request = freezeTransferRequest({ ...request, ownershipPolicy: policy });
|
||||
preview = await options.preview(request);
|
||||
if (!isCurrent()) return false;
|
||||
}
|
||||
}
|
||||
if (request.dropTargetBeforeCreate && !preview.rebuild) throw new Error("TRANSFER_REBUILD_PREVIEW_UNAVAILABLE");
|
||||
if (!(await options.confirm(request, preview)) || !isCurrent()) return false;
|
||||
options.execute(Object.freeze({ ...request, dropTargetConfirmed: request.dropTargetBeforeCreate }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return false;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The shared production prompt also serves as the destructive rebuild confirmation. */
|
||||
export function confirmTransferWithProductionSafety(options: { request: TransferRequest; connection?: ConnectionConfig; reviewText: string; source?: string; confirm: () => Promise<boolean> }): Promise<boolean> {
|
||||
const context = productionContextForDatabase(options.connection, options.request.targetDatabase);
|
||||
if (!context.active) return options.confirm();
|
||||
return useProductionSafetyStore().requestConfirmation({
|
||||
sql: options.reviewText,
|
||||
connectionName: options.connection?.name,
|
||||
database: options.request.targetDatabase,
|
||||
productionDatabases: context.databases,
|
||||
source: options.source,
|
||||
scopeId: options.request.transferId,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user