feat(transfer): add rebuild structure and data mode

This commit is contained in:
HolyeTyrone authored and GitHub committed 2026-09-08 16:46:39 +08:00
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,
});
}
+10 -1
View File
@@ -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?",
+11 -2
View File
@@ -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?",
+10 -1
View File
@@ -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?",
+10 -1
View File
@@ -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} に転送します。開始しますか?",
+11 -2
View File
@@ -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}(으)로 전송합니다. 시작하시겠습니까?",
+11 -2
View File
@@ -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?",
+11 -2
View File
@@ -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},确定开始吗?",
+11 -2
View File
@@ -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},確定開始嗎?",
+7
View File
@@ -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,
+6 -3
View File
@@ -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,
};
+4
View File
@@ -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 {