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,
|
||||
});
|
||||
}
|
||||
@@ -5568,7 +5568,7 @@ export default {
|
||||
crossFamilyViewHint: "Cross-database view transfer: select the tables the view depends on as well, or make sure they already exist in the target database",
|
||||
objectUnsupportedDisabled: "This database does not support transferring this object type",
|
||||
skippedExistingObjects: "Skipped {count} existing object(s)",
|
||||
dataWriteMode: "Data write mode",
|
||||
targetTableHandling: "Target table handling",
|
||||
source: "Source",
|
||||
target: "Target",
|
||||
sourceConnection: "Source Connection",
|
||||
@@ -5596,6 +5596,15 @@ export default {
|
||||
tableNameCaseLower: "Lowercase",
|
||||
tableNameCaseUpper: "Uppercase",
|
||||
batchSize: "Batch size",
|
||||
modeRebuild: "Rebuild structure and data (RENAME + CREATE + INSERT)",
|
||||
modeKeepExisting: "Keep existing tables",
|
||||
modeRebuildStructure: "Rebuild structure (RENAME + CREATE)",
|
||||
rebuildSummary: "Rebuild {count} table(s): existing target tables are renamed to backups first, then rebuilt from the source structure and populated; backups are removed after all steps succeed.",
|
||||
rebuildDanger: "Rebuilding replaces the selected target tables, their existing rows, and target-only metadata. Review the actual targets, backup names, and SQL. Once backups are cleaned up, they cannot be used for recovery.",
|
||||
rebuildDataOnlyDisabled: "Rebuild is unavailable for data-only transfers. Select structure and data or structure only.",
|
||||
rebuildUnsupportedDisabled: "The selected target engine does not support rebuilding target tables.",
|
||||
rebuildPreviewUnavailable: "The backend did not provide a rebuild plan. The transfer has not started; refresh the preview with a backend that supports rebuild.",
|
||||
previewFailed: "Could not prepare the transfer: {message}",
|
||||
start: "Start Transfer",
|
||||
startConfirmTitle: "Confirm transfer",
|
||||
startConfirmMessage: "Transfer {count} selected objects from {source} to {target}. Start now?",
|
||||
|
||||
@@ -5190,7 +5190,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
transfer: {
|
||||
title: "Transferencia de datos",
|
||||
content: "Modo de transferencia",
|
||||
content: "Contenido de la transferencia",
|
||||
contentStructureAndData: "Estructura y datos",
|
||||
contentStructureOnly: "Solo estructura",
|
||||
contentDataOnly: "Solo datos",
|
||||
@@ -5212,7 +5212,7 @@ export default withEnglishFallback({
|
||||
crossFamilyViewHint: "Transferencia de vistas entre bases de datos: seleccione también las tablas de las que depende la vista o asegúrese de que ya existen en la base de datos de destino",
|
||||
objectUnsupportedDisabled: "La base de datos actual no admite la transferencia de este tipo de objeto",
|
||||
skippedExistingObjects: "Se omitieron {count} objetos existentes",
|
||||
dataWriteMode: "Modo de escritura de datos",
|
||||
targetTableHandling: "Tratamiento de las tablas de destino",
|
||||
source: "Origen",
|
||||
target: "Destino",
|
||||
sourceConnection: "Conexión de origen",
|
||||
@@ -5233,6 +5233,15 @@ export default withEnglishFallback({
|
||||
tableNameCaseLower: "Convertir a minúsculas",
|
||||
tableNameCaseUpper: "Convertir a mayúsculas",
|
||||
batchSize: "Tamaño de lote",
|
||||
modeRebuild: "Reconstruir estructura y datos (RENAME + CREATE + INSERT)",
|
||||
modeKeepExisting: "Conservar tablas existentes",
|
||||
modeRebuildStructure: "Reconstruir estructura (RENAME + CREATE)",
|
||||
rebuildSummary: "Se reconstruirán {count} tabla(s): las tablas de destino existentes se renombran como copias de seguridad, se recrean con la estructura de origen y se rellenan; las copias se eliminan al finalizar todos los pasos.",
|
||||
rebuildDanger: "La reconstrucción sustituye las tablas de destino seleccionadas, sus filas existentes y los metadatos exclusivos del destino. Revise los destinos reales, los nombres de respaldo y el SQL. Los respaldos eliminados no se pueden usar para recuperar las tablas.",
|
||||
rebuildDataOnlyDisabled: "La reconstrucción no está disponible para transferencias de solo datos. Seleccione estructura y datos o solo estructura.",
|
||||
rebuildUnsupportedDisabled: "El motor de destino seleccionado no permite reconstruir las tablas de destino.",
|
||||
rebuildPreviewUnavailable: "El backend no devolvió un plan de reconstrucción. La transferencia no se ha iniciado; vuelva a obtener la vista previa con un backend compatible.",
|
||||
previewFailed: "No se pudo preparar la transferencia: {message}",
|
||||
start: "Iniciar transferencia",
|
||||
startConfirmTitle: "Confirmar transferencia",
|
||||
startConfirmMessage: "Se transferirán {count} objetos seleccionados de {source} a {target}. ¿Iniciar ahora?",
|
||||
|
||||
@@ -5210,7 +5210,7 @@ export default withEnglishFallback({
|
||||
crossFamilyViewHint: "Trasferimento di viste tra database: selezionare anche le tabelle da cui la vista dipende, o assicurarsi che esistano già nel database di destinazione",
|
||||
objectUnsupportedDisabled: "Il database corrente non supporta il trasferimento di questo tipo di oggetto",
|
||||
skippedExistingObjects: "Saltati {count} oggetti esistenti",
|
||||
dataWriteMode: "Modalità di scrittura dati",
|
||||
targetTableHandling: "Gestione delle tabelle di destinazione",
|
||||
source: "Sorgente",
|
||||
target: "Destinazione",
|
||||
sourceConnection: "Connessione Sorgente",
|
||||
@@ -5234,6 +5234,15 @@ export default withEnglishFallback({
|
||||
tableNameCaseLower: "Converti in minuscolo",
|
||||
tableNameCaseUpper: "Converti in maiuscolo",
|
||||
batchSize: "Dimensione lotto",
|
||||
modeRebuild: "Ricostruisci struttura e dati (RENAME + CREATE + INSERT)",
|
||||
modeKeepExisting: "Mantieni tabelle esistenti",
|
||||
modeRebuildStructure: "Ricostruisci struttura (RENAME + CREATE)",
|
||||
rebuildSummary: "Verranno ricostruite {count} tabelle: le tabelle di destinazione esistenti vengono prima rinominate come backup, quindi ricreate dalla struttura di origine e popolate; i backup vengono eliminati al termine di tutti i passaggi.",
|
||||
rebuildDanger: "La ricostruzione sostituisce le tabelle di destinazione selezionate, le righe esistenti e i metadati presenti solo nella destinazione. Controllare le destinazioni effettive, i nomi dei backup e il SQL. I backup eliminati non possono essere usati per il ripristino.",
|
||||
rebuildDataOnlyDisabled: "La ricostruzione non è disponibile per i trasferimenti di soli dati. Selezionare struttura e dati oppure solo struttura.",
|
||||
rebuildUnsupportedDisabled: "Il motore di destinazione selezionato non supporta la ricostruzione delle tabelle di destinazione.",
|
||||
rebuildPreviewUnavailable: "Il backend non ha restituito un piano di ricostruzione. Il trasferimento non è iniziato; aggiornare l’anteprima con un backend compatibile.",
|
||||
previewFailed: "Impossibile preparare il trasferimento: {message}",
|
||||
start: "Avvia Trasferimento",
|
||||
startConfirmTitle: "Conferma trasferimento",
|
||||
startConfirmMessage: "Verranno trasferiti {count} oggetti selezionati da {source} a {target}. Avviare ora?",
|
||||
|
||||
@@ -5239,7 +5239,7 @@ export default withEnglishFallback({
|
||||
crossFamilyViewHint: "データベース間ビュー転送:ビューが依存するテーブルも選択するか、ターゲットDBに既に存在することを確認してください",
|
||||
objectUnsupportedDisabled: "現在のデータベースは、このオブジェクトタイプの転送をサポートしていません",
|
||||
skippedExistingObjects: "既存のオブジェクト {count} 件をスキップしました",
|
||||
dataWriteMode: "データ書き込みモード",
|
||||
targetTableHandling: "ターゲットテーブルの処理",
|
||||
source: "ソース",
|
||||
target: "対象",
|
||||
sourceConnection: "ソース接続",
|
||||
@@ -5263,6 +5263,15 @@ export default withEnglishFallback({
|
||||
tableNameCaseLower: "小文字に変換",
|
||||
tableNameCaseUpper: "大文字に変換",
|
||||
batchSize: "バッチサイズ",
|
||||
modeRebuild: "構造とデータを再構築 (RENAME + CREATE + INSERT)",
|
||||
modeKeepExisting: "既存テーブルを保持",
|
||||
modeRebuildStructure: "構造を再構築 (RENAME + CREATE)",
|
||||
rebuildSummary: "{count} 件のテーブルを再構築します:既存のターゲットテーブルをまずバックアップにリネームし、ソース構造から再作成してデータを投入します。すべての手順が成功した後にバックアップを削除します。",
|
||||
rebuildDanger: "再構築により、選択したターゲットテーブル、既存の行、ターゲットのみに存在するメタデータが置き換えられます。実際のターゲット、バックアップ名、SQL を確認してください。削除されたバックアップからは復元できません。",
|
||||
rebuildDataOnlyDisabled: "データのみの転送では再構築できません。「構造とデータ」または「構造のみ」を選択してください。",
|
||||
rebuildUnsupportedDisabled: "選択したターゲットエンジンはテーブルの再構築をサポートしていません。",
|
||||
rebuildPreviewUnavailable: "バックエンドから再構築計画が返されなかったため、転送は開始されていません。再構築プレビューに対応したバックエンドで再試行してください。",
|
||||
previewFailed: "転送を準備できませんでした:{message}",
|
||||
start: "転送開始",
|
||||
startConfirmTitle: "転送の開始確認",
|
||||
startConfirmMessage: "{source} で選択した {count} 個のオブジェクトを {target} に転送します。開始しますか?",
|
||||
|
||||
@@ -4882,7 +4882,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
transfer: {
|
||||
title: "데이터 이전",
|
||||
content: "전송 콘텐츠",
|
||||
content: "전송 내용",
|
||||
contentStructureAndData: "구조 및 데이터",
|
||||
contentStructureOnly: "구조만",
|
||||
contentDataOnly: "데이터만",
|
||||
@@ -4904,7 +4904,7 @@ export default withEnglishFallback({
|
||||
crossFamilyViewHint: "데이터베이스 간 뷰 전송: 뷰가 의존하는 테이블도 선택하거나 대상 DB에 이미 존재하는지 확인하세요",
|
||||
objectUnsupportedDisabled: "현재 데이터베이스는 이 개체 유형의 전송을 지원하지 않습니다",
|
||||
skippedExistingObjects: "기존 개체 {count}개 건너뜀",
|
||||
dataWriteMode: "데이터 쓰기 모드",
|
||||
targetTableHandling: "대상 테이블 처리 방식",
|
||||
source: "소스",
|
||||
target: "대상",
|
||||
sourceConnection: "소스 연결",
|
||||
@@ -4928,6 +4928,15 @@ export default withEnglishFallback({
|
||||
tableNameCaseLower: "소문자",
|
||||
tableNameCaseUpper: "대문자",
|
||||
batchSize: "배치 크기",
|
||||
modeRebuild: "구조 및 데이터 재구축 (RENAME + CREATE + INSERT)",
|
||||
modeKeepExisting: "기존 테이블 유지",
|
||||
modeRebuildStructure: "구조 재구축 (RENAME + CREATE)",
|
||||
rebuildSummary: "{count}개 테이블을 재구축합니다: 기존 대상 테이블을 먼저 백업으로 이름을 바꾸고 소스 구조로 다시 만든 뒤 데이터를 넣습니다. 모든 단계가 성공한 후 백업을 삭제합니다.",
|
||||
rebuildDanger: "재구축하면 선택한 대상 테이블, 기존 행, 대상에만 있는 메타데이터가 교체됩니다. 실제 대상, 백업 이름, SQL을 확인하세요. 삭제된 백업은 복구에 사용할 수 없습니다.",
|
||||
rebuildDataOnlyDisabled: "데이터만 전송할 때는 재구축할 수 없습니다. 구조 및 데이터 또는 구조만을 선택하세요.",
|
||||
rebuildUnsupportedDisabled: "선택한 대상 엔진은 대상 테이블 재구축을 지원하지 않습니다.",
|
||||
rebuildPreviewUnavailable: "백엔드가 재구축 계획을 반환하지 않아 전송을 시작하지 않았습니다. 재구축 미리 보기를 지원하는 백엔드에서 다시 시도하세요.",
|
||||
previewFailed: "전송을 준비할 수 없습니다: {message}",
|
||||
start: "이전 시작",
|
||||
startConfirmTitle: "전송 시작 확인",
|
||||
startConfirmMessage: "{source}에서 선택한 개체 {count}개를 {target}(으)로 전송합니다. 시작하시겠습니까?",
|
||||
|
||||
@@ -5190,7 +5190,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
transfer: {
|
||||
title: "Transferência de Dados",
|
||||
content: "Conteúdo de transferência",
|
||||
content: "Conteúdo da transferência",
|
||||
contentStructureAndData: "Estrutura e dados",
|
||||
contentStructureOnly: "Apenas estrutura",
|
||||
contentDataOnly: "Apenas dados",
|
||||
@@ -5212,7 +5212,7 @@ export default withEnglishFallback({
|
||||
crossFamilyViewHint: "Transferência de views entre bancos: selecione também as tabelas das quais a view depende ou garanta que elas já existam no banco de destino",
|
||||
objectUnsupportedDisabled: "O banco de dados atual não suporta a transferência deste tipo de objeto",
|
||||
skippedExistingObjects: "Ignorado {count} objeto(s) existente(s)",
|
||||
dataWriteMode: "Modo de gravação de dados",
|
||||
targetTableHandling: "Tratamento das tabelas de destino",
|
||||
source: "Origem",
|
||||
target: "Destino",
|
||||
sourceConnection: "Conexão de origem",
|
||||
@@ -5236,6 +5236,15 @@ export default withEnglishFallback({
|
||||
tableNameCaseLower: "Converter para minúsculas",
|
||||
tableNameCaseUpper: "Converter para maiúsculas",
|
||||
batchSize: "Tamanho do lote",
|
||||
modeRebuild: "Reconstruir estrutura e dados (RENAME + CREATE + INSERT)",
|
||||
modeKeepExisting: "Manter tabelas existentes",
|
||||
modeRebuildStructure: "Reconstruir estrutura (RENAME + CREATE)",
|
||||
rebuildSummary: "{count} tabela(s) serão reconstruídas: as tabelas de destino existentes são renomeadas como backups, recriadas com a estrutura de origem e preenchidas; os backups são removidos após todas as etapas.",
|
||||
rebuildDanger: "A reconstrução substitui as tabelas de destino selecionadas, suas linhas existentes e os metadados exclusivos do destino. Revise os destinos reais, os nomes dos backups e o SQL. Backups removidos não podem ser usados para recuperação.",
|
||||
rebuildDataOnlyDisabled: "A reconstrução não está disponível para transferências apenas de dados. Selecione estrutura e dados ou apenas estrutura.",
|
||||
rebuildUnsupportedDisabled: "O mecanismo de destino selecionado não oferece suporte à reconstrução das tabelas de destino.",
|
||||
rebuildPreviewUnavailable: "O backend não retornou um plano de reconstrução. A transferência não foi iniciada; atualize a prévia com um backend compatível.",
|
||||
previewFailed: "Não foi possível preparar a transferência: {message}",
|
||||
start: "Iniciar transferência",
|
||||
startConfirmTitle: "Confirmar transferência",
|
||||
startConfirmMessage: "{count} objetos selecionados de {source} serão transferidos para {target}. Iniciar agora?",
|
||||
|
||||
@@ -5530,7 +5530,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
transfer: {
|
||||
title: "数据传输",
|
||||
content: "传输方式",
|
||||
content: "传输内容",
|
||||
contentStructureAndData: "结构和数据",
|
||||
contentStructureOnly: "仅结构",
|
||||
contentDataOnly: "仅数据",
|
||||
@@ -5552,7 +5552,7 @@ export default withEnglishFallback({
|
||||
crossFamilyViewHint: "跨库视图传输:请同时选择视图依赖的表,或确保目标库已存在这些表",
|
||||
objectUnsupportedDisabled: "当前数据库不支持该对象类型传输",
|
||||
skippedExistingObjects: "跳过 {count} 个已存在对象",
|
||||
dataWriteMode: "数据写入方式",
|
||||
targetTableHandling: "目标表处理方式",
|
||||
source: "源",
|
||||
target: "目标",
|
||||
sourceConnection: "源连接",
|
||||
@@ -5580,6 +5580,15 @@ export default withEnglishFallback({
|
||||
tableNameCaseLower: "转为小写",
|
||||
tableNameCaseUpper: "转为大写",
|
||||
batchSize: "每批行数",
|
||||
modeRebuild: "重建结构和数据 (RENAME + CREATE + INSERT)",
|
||||
modeKeepExisting: "保留已有表",
|
||||
modeRebuildStructure: "重建结构 (RENAME + CREATE)",
|
||||
rebuildSummary: "将重建 {count} 张表:先将现有目标表改名为备份表,按源结构重建并传输数据,全部成功后清理备份。",
|
||||
rebuildDanger: "重建会替换所选目标表、已有数据和仅目标端存在的元数据。请核对实际目标、备份名和 SQL;备份清理后将无法再用于恢复。",
|
||||
rebuildDataOnlyDisabled: "仅数据模式不支持重建,请选择“结构和数据”或“仅结构”。",
|
||||
rebuildUnsupportedDisabled: "当前目标引擎不支持重建目标表。",
|
||||
rebuildPreviewUnavailable: "后端未返回重建计划,传输尚未开始。请使用支持重建预览的后端后重新预览。",
|
||||
previewFailed: "无法准备传输:{message}",
|
||||
start: "开始传输",
|
||||
startConfirmTitle: "确认开始传输",
|
||||
startConfirmMessage: "将把 {source} 中选中的 {count} 个对象传输到 {target},确定开始吗?",
|
||||
|
||||
@@ -4510,7 +4510,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
transfer: {
|
||||
title: "資料傳輸",
|
||||
content: "傳輸方式",
|
||||
content: "傳輸內容",
|
||||
contentStructureAndData: "結構和資料",
|
||||
contentStructureOnly: "僅結構",
|
||||
contentDataOnly: "僅資料",
|
||||
@@ -4532,7 +4532,7 @@ export default withEnglishFallback({
|
||||
crossFamilyViewHint: "跨庫檢視傳輸:請同時選擇檢視依賴的表,或確保目標庫已存在這些表",
|
||||
objectUnsupportedDisabled: "目前資料庫不支援該物件類型傳輸",
|
||||
skippedExistingObjects: "跳過 {count} 個已存在物件",
|
||||
dataWriteMode: "資料寫入方式",
|
||||
targetTableHandling: "目標表處理方式",
|
||||
source: "來源",
|
||||
target: "目標",
|
||||
sourceConnection: "來源連線",
|
||||
@@ -4551,6 +4551,15 @@ export default withEnglishFallback({
|
||||
modeOverwrite: "覆蓋 (TRUNCATE + INSERT)",
|
||||
modeUpsert: "更新插入 (按主鍵 INSERT 或 UPDATE)",
|
||||
batchSize: "每批筆數",
|
||||
modeRebuild: "重建結構和資料 (RENAME + CREATE + INSERT)",
|
||||
modeKeepExisting: "保留既有表",
|
||||
modeRebuildStructure: "重建結構 (RENAME + CREATE)",
|
||||
rebuildSummary: "將重建 {count} 張資料表:先將既有目標表改名為備份表,依來源結構重建並傳輸資料,全部成功後清理備份。",
|
||||
rebuildDanger: "重建會取代所選目標表、既有資料和僅目標端存在的中繼資料。請核對實際目標、備份名稱和 SQL;備份清理後將無法再用於還原。",
|
||||
rebuildDataOnlyDisabled: "僅資料模式不支援重建,請選擇「結構和資料」或「僅結構」。",
|
||||
rebuildUnsupportedDisabled: "目前目標引擎不支援重建目標表。",
|
||||
rebuildPreviewUnavailable: "後端未傳回重建計畫,傳輸尚未開始。請使用支援重建預覽的後端後重新預覽。",
|
||||
previewFailed: "無法準備傳輸:{message}",
|
||||
start: "開始傳輸",
|
||||
startConfirmTitle: "確認開始傳輸",
|
||||
startConfirmMessage: "將把 {source} 中選中的 {count} 個物件傳輸到 {target},確定開始嗎?",
|
||||
|
||||
@@ -4626,11 +4626,18 @@ export interface TransferRequest {
|
||||
quoteTargetColumnNames: boolean;
|
||||
ownershipPolicy?: TransferOwnershipPolicy;
|
||||
batchSize: number;
|
||||
dropTargetBeforeCreate: boolean;
|
||||
dropTargetConfirmed: boolean;
|
||||
}
|
||||
|
||||
export interface TransferOwnershipPreview {
|
||||
missingOwners: string[];
|
||||
targetOwner: string;
|
||||
rebuild?: {
|
||||
sql: string;
|
||||
tables: Array<{ sourceTable: string; targetTable: string; backupTable?: string }>;
|
||||
warnings: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface TransferProgress {
|
||||
|
||||
@@ -82,6 +82,37 @@ describe("transferTaskStore", () => {
|
||||
expect(store.tasks[0]?.config.quoteTargetColumnNames).toBe(true);
|
||||
});
|
||||
|
||||
it("loads a legacy rebuild task as append without restoring its previous confirmation", async () => {
|
||||
vi.mocked(api.loadTransferTaskLibrary).mockResolvedValue({
|
||||
version: 1,
|
||||
folders: [],
|
||||
tasks: [{ id: "legacy-rebuild", name: "legacy rebuild", config: makeConfig({ mode: "upsert", dropTargetBeforeCreate: true, dropTargetConfirmed: true }) }],
|
||||
});
|
||||
const store = useTransferTaskStore();
|
||||
|
||||
await store.initFromStorage();
|
||||
|
||||
expect(store.getTask("legacy-rebuild")?.config).toMatchObject({ mode: "append", dropTargetBeforeCreate: true, dropTargetConfirmed: false });
|
||||
});
|
||||
|
||||
it("saves mutually exclusive rebuild options without persisting request confirmation", async () => {
|
||||
const store = useTransferTaskStore();
|
||||
|
||||
const task = await store.saveTask({ name: "rebuild", config: makeConfig({ mode: "overwrite", dropTargetBeforeCreate: true, dropTargetConfirmed: true }) });
|
||||
|
||||
expect(task.config).toMatchObject({ mode: "append", dropTargetBeforeCreate: true, dropTargetConfirmed: false });
|
||||
const persisted = vi.mocked(api.saveTransferTaskLibrary).mock.calls.at(-1)?.[0] as TransferTaskLibrary;
|
||||
expect(persisted.tasks[0]?.config).toMatchObject({ mode: "append", dropTargetBeforeCreate: true, dropTargetConfirmed: false });
|
||||
});
|
||||
|
||||
it.each(["append", "overwrite", "upsert"] as const)("keeps ordinary %s tasks unchanged while dropping stale confirmation", async (mode) => {
|
||||
const store = useTransferTaskStore();
|
||||
|
||||
const task = await store.saveTask({ name: mode, config: makeConfig({ mode, dropTargetBeforeCreate: false, dropTargetConfirmed: true }) });
|
||||
|
||||
expect(task.config).toMatchObject({ mode, dropTargetBeforeCreate: false, dropTargetConfirmed: false });
|
||||
});
|
||||
|
||||
it("refuses to overwrite a persisted library with invalid entries", async () => {
|
||||
vi.mocked(api.loadTransferTaskLibrary).mockResolvedValue({
|
||||
version: 1,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineStore } from "pinia";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { TransferTask, TransferTaskConfig, TransferTaskFolder, TransferTaskLibrary } from "@/types/database";
|
||||
import { resolveTransferStrategy, transferStrategyOptions } from "@/components/transfer/transferStrategy";
|
||||
|
||||
export class TransferTaskNameConflictError extends Error {
|
||||
readonly code = "TRANSFER_TASK_NAME_CONFLICT";
|
||||
@@ -87,10 +88,11 @@ function normalizeTask(raw: unknown): TransferTask | null {
|
||||
targetSchema: config.targetSchema || undefined,
|
||||
objects,
|
||||
content: config.content ?? "structureAndData",
|
||||
mode: config.mode ?? "append",
|
||||
...transferStrategyOptions(resolveTransferStrategy(config)),
|
||||
targetTableNameCase: config.targetTableNameCase ?? "preserve",
|
||||
quoteTargetColumnNames: config.quoteTargetColumnNames ?? true,
|
||||
batchSize: typeof config.batchSize === "number" && config.batchSize > 0 ? config.batchSize : 1000,
|
||||
dropTargetConfirmed: false,
|
||||
},
|
||||
createdAt: candidate.createdAt || nowIso(),
|
||||
updatedAt: candidate.updatedAt || nowIso(),
|
||||
@@ -281,14 +283,15 @@ export const useTransferTaskStore = defineStore("transferTasks", () => {
|
||||
const folderId = Object.prototype.hasOwnProperty.call(input, "folderId") ? input.folderId || undefined : existing?.folderId;
|
||||
ensureTaskNameAvailable(trimmed, folderId, input.id);
|
||||
const timestamp = nowIso();
|
||||
const config: TransferTaskConfig = { ...input.config, ...transferStrategyOptions(resolveTransferStrategy(input.config)), dropTargetConfirmed: false };
|
||||
const task: TransferTask = existing
|
||||
? { ...existing, folderId, name: trimmed, config: input.config, updatedAt: timestamp }
|
||||
? { ...existing, folderId, name: trimmed, config, updatedAt: timestamp }
|
||||
: {
|
||||
id: uuid(),
|
||||
folderId,
|
||||
name: trimmed,
|
||||
orderIndex: maxOrderIndex(tasks.value.filter((item) => (item.folderId || "") === (folderId || ""))) + 1,
|
||||
config: input.config,
|
||||
config,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
@@ -1469,6 +1469,10 @@ export interface TransferTaskConfig {
|
||||
targetTableNameCase: TransferTableNameCase;
|
||||
quoteTargetColumnNames: boolean;
|
||||
batchSize: number;
|
||||
/** Legacy-compatible rebuild flag; true takes precedence over the saved DML mode. */
|
||||
dropTargetBeforeCreate?: boolean;
|
||||
/** Legacy field only. Saved confirmation is always ignored and reset to false. */
|
||||
dropTargetConfirmed?: boolean;
|
||||
}
|
||||
|
||||
export interface TransferTask {
|
||||
|
||||
Reference in new issue
Block a user