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,
});
}