From 1b80a17eff14d962ad5a38c655a579a019ec40d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 23:42:41 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(sql-editor):=20data=20migrate=20from?= =?UTF-8?q?=20side-by-side=20compare=20(=E2=89=A4500=20ops)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key-based insert/update/delete with checkboxes, optional identity IDs, progress panel, destination snapshot, and data_migrate_runs history. Over 500 ops toasts Server Beam instructions instead of applying. Co-authored-by: huy.phan9 --- apps/e2e/src/tests/sql-editor-sqlite.test.ts | 15 +- apps/web/src/backend/api/routes.ts | 116 ++++ apps/web/src/backend/database/schema.ts | 31 + .../modules/data-migrate-history.module.ts | 251 ++++++++ apps/web/src/frontend/api/dataMigrateApi.ts | 101 +++ .../components/sql-editor/DataMigrateBar.tsx | 589 ++++++++++++++++++ .../components/sql-editor/ResultsPanel.tsx | 68 +- .../src/frontend/lib/dataMigratePlans.test.ts | 49 ++ apps/web/src/frontend/lib/dataMigratePlans.ts | 160 +++++ .../src/frontend/lib/resultRowDiff.test.ts | 102 +++ apps/web/src/frontend/lib/resultRowDiff.ts | 201 ++++++ docs/USER_GUIDE.md | 13 +- 12 files changed, 1688 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/backend/modules/data-migrate-history.module.ts create mode 100644 apps/web/src/frontend/api/dataMigrateApi.ts create mode 100644 apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx create mode 100644 apps/web/src/frontend/lib/dataMigratePlans.test.ts create mode 100644 apps/web/src/frontend/lib/dataMigratePlans.ts create mode 100644 apps/web/src/frontend/lib/resultRowDiff.test.ts create mode 100644 apps/web/src/frontend/lib/resultRowDiff.ts diff --git a/apps/e2e/src/tests/sql-editor-sqlite.test.ts b/apps/e2e/src/tests/sql-editor-sqlite.test.ts index 5c829a0f..12a3b7b9 100644 --- a/apps/e2e/src/tests/sql-editor-sqlite.test.ts +++ b/apps/e2e/src/tests/sql-editor-sqlite.test.ts @@ -134,10 +134,21 @@ describe.skipIf(!ready)('SQL Editor · SQLite multi-credential', () => { expect(anyDiff).toBeGreaterThanOrEqual(modified); const results = await sql.resultsText(); - expect(results).toMatch(/baseline/i); + expect(results).toMatch(/baseline|source/i); expect(results).toMatch(/differ|match/i); - // Capture the colored compare view for the PR / walkthrough. + // Data migrate bar (≤500) with insert/update/delete checkboxes. + await driver.waitForSelector('[data-testid="sql-data-migrate-bar-0"]', { + timeout: 10_000, + }); + expect(await driver.locator('[data-testid="sql-data-migrate-insert-0"]').isVisible()).toBe( + true + ); + expect(await driver.locator('[data-testid="sql-data-migrate-identity-0"]').isVisible()).toBe( + true + ); + + // Capture the colored compare + migrate bar for the PR / walkthrough. await saveScreenshot(driver, 'sql-editor-data-compare'); await saveSeoScreenshot(driver, 'sql-editor-data-compare'); diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index 808b966c..7cf57b3e 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -27,6 +27,11 @@ import { probeDbaUtility } from './dba-utilities'; const WORKSPACE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..'); import { ConnectionStore } from '../modules/connection-store.module'; import { MigrationHistoryStore, type MigrationObjectResult, type MigrationRunStatus } from '../modules/migration-history.module'; +import { + DataMigrateHistoryStore, + type DataMigrateOpResult, + type DataMigrateRunStatus, +} from '../modules/data-migrate-history.module'; import { AppSettingsStore } from '../modules/app-settings.module'; import { rateLimit } from './rate-limit'; import { @@ -75,6 +80,7 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt const migrationModule = new MigrationModule(); const sqlGenerator = new SqlGeneratorModule(); const migrationHistory = new MigrationHistoryStore(); + const dataMigrateHistory = new DataMigrateHistoryStore(); const appSettings = new AppSettingsStore(); // Feature services. These own the business logic and its permission checks; @@ -783,6 +789,116 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt res.status(removed ? 200 : 404).json({ ok: removed }); }); + // --- Data migrate history (SQL Editor side-by-side row ops) --------------- + router.get('/data-migrations', requirePermissions('editor.dml'), async (req: Request, res: Response) => { + res.json({ runs: await dataMigrateHistory.list((req as AuthedRequest).userId!) }); + }); + + router.post( + '/data-migrations/start', + requirePermissions('editor.dml'), + async (req: Request, res: Response) => { + const body = req.body as { + dialect?: string; + sourceHost?: string; + targetHost?: string; + database?: string; + schema?: string; + tableName?: string; + rowCount?: number; + opsEnabled?: { insert?: boolean; update?: boolean; delete?: boolean }; + includeIdentity?: boolean; + keyColumns?: string[]; + script?: string; + snapshotJson?: string; + }; + if (!body.dialect || typeof body.script !== 'string') { + res.status(400).json({ error: 'dialect and script are required' }); + return; + } + const id = await dataMigrateHistory.start((req as AuthedRequest).userId!, { + dialect: body.dialect, + sourceHost: body.sourceHost, + targetHost: body.targetHost, + database: body.database, + schema: body.schema, + tableName: body.tableName, + rowCount: typeof body.rowCount === 'number' ? body.rowCount : 0, + opsEnabled: { + insert: Boolean(body.opsEnabled?.insert), + update: Boolean(body.opsEnabled?.update), + delete: Boolean(body.opsEnabled?.delete), + }, + includeIdentity: Boolean(body.includeIdentity), + keyColumns: Array.isArray(body.keyColumns) + ? body.keyColumns.filter((k): k is string => typeof k === 'string') + : [], + script: body.script, + snapshotJson: body.snapshotJson, + }); + res.json({ id }); + } + ); + + router.post( + '/data-migrations/:id/finish', + requirePermissions('editor.dml'), + async (req: Request, res: Response) => { + const body = req.body as { + status?: DataMigrateRunStatus; + results?: DataMigrateOpResult[]; + error?: string; + }; + const status = body.status; + if (status !== 'SUCCESS' && status !== 'PARTIAL_SUCCESS' && status !== 'FAILED') { + res.status(400).json({ error: 'Invalid status' }); + return; + } + const run = await dataMigrateHistory.get( + (req as AuthedRequest).userId!, + String(req.params.id) + ); + if (!run) { + res.status(404).json({ error: 'Data migrate run not found' }); + return; + } + await dataMigrateHistory.finish(String(req.params.id), { + status, + results: Array.isArray(body.results) ? body.results : [], + error: body.error, + }); + res.json({ ok: true }); + } + ); + + router.get( + '/data-migrations/:id', + requirePermissions('editor.dml'), + async (req: Request, res: Response) => { + const run = await dataMigrateHistory.get( + (req as AuthedRequest).userId!, + String(req.params.id) + ); + if (!run) { + res.status(404).json({ error: 'Data migrate run not found' }); + return; + } + res.json({ run }); + } + ); + + router.delete( + '/data-migrations/:id', + requirePermissions('editor.dml'), + async (req: Request, res: Response) => { + const removed = await dataMigrateHistory.remove( + (req as AuthedRequest).userId!, + String(req.params.id) + ); + res.status(removed ? 200 : 404).json({ ok: removed }); + } + ); + return router; } diff --git a/apps/web/src/backend/database/schema.ts b/apps/web/src/backend/database/schema.ts index 6553a509..f009172a 100644 --- a/apps/web/src/backend/database/schema.ts +++ b/apps/web/src/backend/database/schema.ts @@ -199,6 +199,37 @@ const MIGRATIONS: Migration[] = [ ]; }, }, + { + id: 10, + name: 'data_migrate_runs', + statements: (d) => { + const t = types(d); + return [ + `CREATE TABLE IF NOT EXISTS data_migrate_runs ( + id ${t.id} PRIMARY KEY, + user_id ${t.id} NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status ${t.str} NOT NULL, + dialect ${t.str} NOT NULL, + source_host ${t.str}, + target_host ${t.str}, + database_name ${t.str}, + "schema" ${t.str}, + table_name ${t.str}, + row_count ${t.int} NOT NULL DEFAULT 0, + ops_json ${t.big}, + include_identity ${t.int} NOT NULL DEFAULT 0, + key_columns_json ${t.big}, + script ${t.big}, + snapshot_json ${t.big}, + results_json ${t.big}, + error ${t.big}, + started_at ${t.ts} NOT NULL, + finished_at ${t.ts} + )`, + `CREATE INDEX idx_data_migrate_runs_user ON data_migrate_runs(user_id, started_at DESC)`, + ]; + }, + }, ]; const SIGNUP_WIZARD_SHOWN_KEY = 'signup.wizard_shown'; diff --git a/apps/web/src/backend/modules/data-migrate-history.module.ts b/apps/web/src/backend/modules/data-migrate-history.module.ts new file mode 100644 index 00000000..3c5ce008 --- /dev/null +++ b/apps/web/src/backend/modules/data-migrate-history.module.ts @@ -0,0 +1,251 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Per-user log of SQL Editor data-migrate runs (row ops from side-by-side compare). + * Separate from Schema Sync `migration_runs`. + */ +import { randomUUID } from 'node:crypto'; +import { getStore } from '../database/store'; + +export type DataMigrateRunStatus = + | 'RUNNING' + | 'SUCCESS' + | 'PARTIAL_SUCCESS' + | 'FAILED'; + +export interface DataMigrateOpResult { + op: 'insert' | 'update' | 'delete'; + key: string; + status: 'SUCCESS' | 'FAILED' | 'SKIPPED'; + error?: string; +} + +export interface DataMigrateRunSummary { + id: string; + status: DataMigrateRunStatus; + dialect: string; + sourceHost?: string; + targetHost?: string; + database?: string; + schema?: string; + tableName?: string; + rowCount: number; + opsEnabled: { insert: boolean; update: boolean; delete: boolean }; + includeIdentity: boolean; + error?: string; + startedAt: string; + finishedAt?: string; +} + +export interface DataMigrateRunDetail extends DataMigrateRunSummary { + script?: string; + snapshotJson?: string; + keyColumns: string[]; + results: DataMigrateOpResult[]; +} + +interface Row { + id: string; + status: string; + dialect: string; + source_host: string | null; + target_host: string | null; + database_name: string | null; + schema: string | null; + table_name: string | null; + row_count: number; + ops_json: string | null; + include_identity: number; + key_columns_json: string | null; + script: string | null; + snapshot_json: string | null; + results_json: string | null; + error: string | null; + started_at: string; + finished_at: string | null; +} + +const MAX_RUNS_PER_USER = 200; +const MAX_TEXT_LEN = 1_000_000; + +function cap(text: string | undefined, max = MAX_TEXT_LEN): string | undefined { + if (text == null) return text; + return text.length > max ? `${text.slice(0, max)}\n… (truncated)` : text; +} + +function parseOps(raw: string | null): { insert: boolean; update: boolean; delete: boolean } { + try { + const o = raw ? (JSON.parse(raw) as Record) : {}; + return { + insert: Boolean(o.insert), + update: Boolean(o.update), + delete: Boolean(o.delete), + }; + } catch { + return { insert: false, update: false, delete: false }; + } +} + +export class DataMigrateHistoryStore { + async start( + userId: string, + input: { + dialect: string; + sourceHost?: string; + targetHost?: string; + database?: string; + schema?: string; + tableName?: string; + rowCount: number; + opsEnabled: { insert: boolean; update: boolean; delete: boolean }; + includeIdentity: boolean; + keyColumns: string[]; + script: string; + snapshotJson?: string; + } + ): Promise { + const id = randomUUID(); + const store = await getStore(); + await store.run( + `INSERT INTO data_migrate_runs + (id, user_id, status, dialect, source_host, target_host, database_name, "schema", + table_name, row_count, ops_json, include_identity, key_columns_json, script, snapshot_json, started_at) + VALUES (?, ?, 'RUNNING', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + userId, + input.dialect, + input.sourceHost ?? null, + input.targetHost ?? null, + input.database ?? null, + input.schema ?? null, + input.tableName ?? null, + input.rowCount, + JSON.stringify(input.opsEnabled), + input.includeIdentity ? 1 : 0, + JSON.stringify(input.keyColumns), + cap(input.script) ?? null, + cap(input.snapshotJson) ?? null, + new Date().toISOString(), + ] + ); + await this.prune(userId); + return id; + } + + private async prune(userId: string): Promise { + const store = await getStore(); + await store.run( + `DELETE FROM data_migrate_runs + WHERE user_id = ? + AND id NOT IN ( + SELECT id FROM ( + SELECT id FROM data_migrate_runs WHERE user_id = ? ORDER BY started_at DESC LIMIT ? + ) AS keep + )`, + [userId, userId, MAX_RUNS_PER_USER] + ); + } + + async finish( + id: string, + outcome: { + status: DataMigrateRunStatus; + results: DataMigrateOpResult[]; + error?: string; + } + ): Promise { + const store = await getStore(); + await store.run( + `UPDATE data_migrate_runs + SET status = ?, results_json = ?, error = ?, finished_at = ? + WHERE id = ?`, + [ + outcome.status, + JSON.stringify(outcome.results ?? []), + outcome.error ?? null, + new Date().toISOString(), + id, + ] + ); + } + + private summary(r: Row): DataMigrateRunSummary { + return { + id: r.id, + status: r.status as DataMigrateRunStatus, + dialect: r.dialect, + sourceHost: r.source_host ?? undefined, + targetHost: r.target_host ?? undefined, + database: r.database_name ?? undefined, + schema: r.schema ?? undefined, + tableName: r.table_name ?? undefined, + rowCount: r.row_count, + opsEnabled: parseOps(r.ops_json), + includeIdentity: Boolean(r.include_identity), + error: r.error ?? undefined, + startedAt: r.started_at, + finishedAt: r.finished_at ?? undefined, + }; + } + + async list(userId: string, limit = 100): Promise { + const store = await getStore(); + const rows = await store.all( + `SELECT id, status, dialect, source_host, target_host, database_name, "schema", table_name, + row_count, ops_json, include_identity, error, started_at, finished_at + FROM data_migrate_runs WHERE user_id = ? ORDER BY started_at DESC LIMIT ?`, + [userId, limit] + ); + return rows.map((r) => this.summary(r)); + } + + async get(userId: string, id: string): Promise { + const store = await getStore(); + const r = await store.get( + 'SELECT * FROM data_migrate_runs WHERE id = ? AND user_id = ?', + [id, userId] + ); + if (!r) return null; + let results: DataMigrateOpResult[] = []; + let keyColumns: string[] = []; + try { + results = r.results_json ? (JSON.parse(r.results_json) as DataMigrateOpResult[]) : []; + } catch { + /* ignore */ + } + try { + keyColumns = r.key_columns_json + ? (JSON.parse(r.key_columns_json) as string[]) + : []; + } catch { + /* ignore */ + } + return { + ...this.summary(r), + script: r.script ?? undefined, + snapshotJson: r.snapshot_json ?? undefined, + keyColumns, + results, + }; + } + + async remove(userId: string, id: string): Promise { + const store = await getStore(); + const result = await store.run( + 'DELETE FROM data_migrate_runs WHERE id = ? AND user_id = ?', + [id, userId] + ); + return result.changes > 0; + } + + async clear(userId: string): Promise { + const store = await getStore(); + const result = await store.run('DELETE FROM data_migrate_runs WHERE user_id = ?', [ + userId, + ]); + return result.changes; + } +} diff --git a/apps/web/src/frontend/api/dataMigrateApi.ts b/apps/web/src/frontend/api/dataMigrateApi.ts new file mode 100644 index 00000000..a08978f4 --- /dev/null +++ b/apps/web/src/frontend/api/dataMigrateApi.ts @@ -0,0 +1,101 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { getApiBase, parseJsonResponse } from './apiBase'; + +export type DataMigrateRunStatus = + | 'RUNNING' + | 'SUCCESS' + | 'PARTIAL_SUCCESS' + | 'FAILED'; + +export interface DataMigrateOpResult { + op: 'insert' | 'update' | 'delete'; + key: string; + status: 'SUCCESS' | 'FAILED' | 'SKIPPED'; + error?: string; +} + +export interface DataMigrateRunSummary { + id: string; + status: DataMigrateRunStatus; + dialect: string; + sourceHost?: string; + targetHost?: string; + database?: string; + schema?: string; + tableName?: string; + rowCount: number; + opsEnabled: { insert: boolean; update: boolean; delete: boolean }; + includeIdentity: boolean; + error?: string; + startedAt: string; + finishedAt?: string; +} + +export interface DataMigrateRunDetail extends DataMigrateRunSummary { + script?: string; + snapshotJson?: string; + keyColumns: string[]; + results: DataMigrateOpResult[]; +} + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${getApiBase()}${path}`, { + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + ...init, + }); + return parseJsonResponse(res, { allowEmpty: true }); +} + +export async function apiStartDataMigrate(input: { + dialect: string; + sourceHost?: string; + targetHost?: string; + database?: string; + schema?: string; + tableName?: string; + rowCount: number; + opsEnabled: { insert: boolean; update: boolean; delete: boolean }; + includeIdentity: boolean; + keyColumns: string[]; + script: string; + snapshotJson?: string; +}): Promise { + const { id } = await request<{ id: string }>('/data-migrations/start', { + method: 'POST', + body: JSON.stringify(input), + }); + return id; +} + +export async function apiFinishDataMigrate( + id: string, + outcome: { + status: DataMigrateRunStatus; + results: DataMigrateOpResult[]; + error?: string; + } +): Promise { + await request(`/data-migrations/${id}/finish`, { + method: 'POST', + body: JSON.stringify(outcome), + }); +} + +export async function apiListDataMigrations(): Promise { + const { runs } = await request<{ runs: DataMigrateRunSummary[] }>('/data-migrations'); + return runs; +} + +export async function apiGetDataMigration(id: string): Promise { + const { run } = await request<{ run: DataMigrateRunDetail }>(`/data-migrations/${id}`); + return run; +} + +export async function apiDeleteDataMigration(id: string): Promise { + await request(`/data-migrations/${id}`, { method: 'DELETE' }); +} diff --git a/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx b/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx new file mode 100644 index 00000000..b8bc5598 --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx @@ -0,0 +1,589 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Side-by-side data migrate: key-based insert/update/delete onto a destination + * grid (≤500 ops). Larger sets toast with Server Beam instructions. + */ +import React, { useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { ArrowRightLeft, History, Loader2, X } from 'lucide-react'; +import { executeSql } from '../../api/sqlApi'; +import { + apiFinishDataMigrate, + apiGetDataMigration, + apiListDataMigrations, + apiStartDataMigrate, + type DataMigrateOpResult, + type DataMigrateRunDetail, + type DataMigrateRunSummary, +} from '../../api/dataMigrateApi'; +import { buildDataMigratePlans, buildDestSnapshotJson } from '../../lib/dataMigratePlans'; +import { + classifyRowsByKey, + DATA_MIGRATE_ROW_CAP, + selectMigrateOps, + type ClassifiedRowDiff, +} from '../../lib/resultRowDiff'; +import { assessPeekEditability, resolvePeekKeyColumns } from '../../lib/rowDml'; +import { singleTableForResultEdit } from '../../lib/tablePreview'; +import type { TableSchema } from '../../lib/types'; +import { toast } from '../../store/toastStore'; +import { useAuthStore } from '../../store/authStore'; +import { useSqlEditorStore } from '../../store/useSqlEditorStore'; +import { useSyncStore } from '../../store/useSyncStore'; +import { SQL_ICON_STROKE } from './sqlIconStyle'; + +export interface DataMigrateGrid { + connectionId: string; + dialect: string; + label: string; + columns: string[]; + rows: unknown[][]; + statementSql?: string; +} + +type ProgressItem = { + keyLabel: string; + op: ClassifiedRowDiff['op']; + status: 'pending' | 'running' | 'ok' | 'fail'; + error?: string; +}; + +interface Props { + statementIndex: number; + source: DataMigrateGrid; + dest: DataMigrateGrid; + onAfterMigrate?: () => void; + onOpenServerBeamSample?: () => void; +} + +export const DataMigrateBar: React.FC = ({ + statementIndex, + source, + dest, + onAfterMigrate, + onOpenServerBeamSample, +}) => { + const canDml = useAuthStore((s) => s.can('editor.dml')); + const sessionPasswords = useSqlEditorStore((s) => s.sessionPasswords); + const schemaCache = useSqlEditorStore((s) => s.schemaCache); + const connections = useSyncStore((s) => s.connections); + const destConn = connections.find((c) => c.id === dest.connectionId); + const sourceConn = connections.find((c) => c.id === source.connectionId); + const destSchema = destConn?.schema; + const tables = schemaCache[dest.connectionId]?.tables; + + const editTarget = useMemo(() => { + if (!source.statementSql) return { ok: false as const, reason: 'No statement SQL' }; + return singleTableForResultEdit(source.statementSql, tables, destSchema); + }, [source.statementSql, tables, destSchema]); + + const table: TableSchema | undefined = editTarget.ok ? editTarget.table : undefined; + const tableName = table?.name ?? ''; + + const defaultKeys = useMemo( + () => resolvePeekKeyColumns(table, source.columns).map((k) => k.name), + [table, source.columns] + ); + + const [keyNames, setKeyNames] = useState([]); + useEffect(() => { + setKeyNames(defaultKeys.length ? defaultKeys : source.columns.slice(0, 1)); + }, [defaultKeys.join('\0'), source.columns.join('\0')]); + + const [doInsert, setDoInsert] = useState(true); + const [doUpdate, setDoUpdate] = useState(true); + const [doDelete, setDoDelete] = useState(false); + const [includeIdentity, setIncludeIdentity] = useState(false); + const [applying, setApplying] = useState(false); + const [progress, setProgress] = useState(null); + const [historyOpen, setHistoryOpen] = useState(false); + const [historyRuns, setHistoryRuns] = useState([]); + const [historyDetail, setHistoryDetail] = useState(null); + + const editability = useMemo( + () => assessPeekEditability({ dialect: dest.dialect, table, resultColumns: source.columns }), + [dest.dialect, table, source.columns] + ); + + const classification = useMemo( + () => + classifyRowsByKey({ + source: { columns: source.columns, rows: source.rows }, + dest: { columns: dest.columns, rows: dest.rows }, + keyNames, + }), + [source.columns, source.rows, dest.columns, dest.rows, keyNames] + ); + + const selected = useMemo( + () => + selectMigrateOps(classification, { + insert: doInsert, + update: doUpdate, + delete: doDelete, + }), + [classification, doInsert, doUpdate, doDelete] + ); + + const toggleKey = (name: string) => { + setKeyNames((prev) => + prev.some((k) => k.toLowerCase() === name.toLowerCase()) + ? prev.filter((k) => k.toLowerCase() !== name.toLowerCase()) + : [...prev, name] + ); + }; + + const openHistory = async () => { + setHistoryOpen(true); + try { + const runs = await apiListDataMigrations(); + setHistoryRuns(runs); + if (runs[0]) { + setHistoryDetail(await apiGetDataMigration(runs[0].id)); + } else { + setHistoryDetail(null); + } + } catch (e) { + toast({ + tone: 'warning', + title: 'Could not load data migrate history', + body: e instanceof Error ? e.message : String(e), + }); + } + }; + + const apply = async () => { + if (applying || !canDml) return; + if (!editTarget.ok || !table) { + toast({ + tone: 'warning', + title: 'Data migrate needs a single-table SELECT', + body: editTarget.ok ? 'Table not found in schema cache.' : editTarget.reason, + }); + return; + } + if (keyNames.length === 0) { + toast({ tone: 'warning', title: 'Select at least one key column' }); + return; + } + if (selected.uncappedCount === 0) { + toast({ tone: 'info', title: 'Nothing to migrate', body: 'Grids match for the selected ops.' }); + return; + } + if (selected.uncappedCount > DATA_MIGRATE_ROW_CAP) { + toast({ + tone: 'warning', + title: `Over ${DATA_MIGRATE_ROW_CAP} row ops — use Server Beam`, + body: + `This compare has ${selected.uncappedCount} insert/update/delete ops. ` + + `Side-by-side migrate is limited to ${DATA_MIGRATE_ROW_CAP} rows. ` + + 'Check source then target Destinations, turn Safe mode off, and run the ' + + 'Server Beam chunked sample (Bookmarks → Add samples).', + actionButtonLabel: 'Insert Server Beam sample', + onAction: onOpenServerBeamSample, + durationMs: 14_000, + }); + return; + } + + const { plans, errors } = buildDataMigratePlans({ + tableName, + dialect: dest.dialect, + sourceColumns: source.columns, + destColumns: dest.columns, + keyNames, + ops: selected.ops, + includeIdentity, + identityColumns: editability.identityColumns, + }); + if (errors.length) { + toast({ + tone: 'warning', + title: 'Some plans could not be built', + body: errors.slice(0, 3).join(' · '), + }); + } + if (plans.length === 0) return; + + const snapshotJson = buildDestSnapshotJson({ + destColumns: dest.columns, + ops: selected.ops, + }); + const script = plans.map((p) => `-- ${p.op} ${p.keyLabel}\n${p.plan.displaySql};`).join('\n\n'); + + setApplying(true); + setProgress( + plans.map((p) => ({ + keyLabel: p.keyLabel, + op: p.op, + status: 'pending' as const, + })) + ); + + let runId: string | null = null; + try { + runId = await apiStartDataMigrate({ + dialect: dest.dialect, + sourceHost: sourceConn?.host || source.label, + targetHost: destConn?.host || dest.label, + database: destConn?.database, + schema: destConn?.schema, + tableName, + rowCount: plans.length, + opsEnabled: { insert: doInsert, update: doUpdate, delete: doDelete }, + includeIdentity, + keyColumns: keyNames, + script, + snapshotJson, + }); + } catch (e) { + toast({ + tone: 'warning', + title: 'Could not start history record', + body: e instanceof Error ? e.message : String(e), + }); + } + + const results: DataMigrateOpResult[] = []; + let failCount = 0; + + for (let i = 0; i < plans.length; i++) { + const item = plans[i]!; + setProgress((prev) => + prev + ? prev.map((p, idx) => (idx === i ? { ...p, status: 'running' } : p)) + : prev + ); + try { + const { results: execResults } = await executeSql( + { + connectionId: dest.connectionId, + password: sessionPasswords[dest.connectionId] || undefined, + schema: destConn?.schema?.trim() || undefined, + }, + [item.plan.sql], + undefined, + undefined, + item.plan.params.length ? [item.plan.params] : undefined, + { datagridAction: item.op } + ); + const failed = execResults.find((r) => !r.ok); + if (failed && !failed.ok) { + failCount += 1; + results.push({ + op: item.op, + key: item.keyLabel, + status: 'FAILED', + error: failed.error, + }); + setProgress((prev) => + prev + ? prev.map((p, idx) => + idx === i ? { ...p, status: 'fail', error: failed.error } : p + ) + : prev + ); + } else { + results.push({ op: item.op, key: item.keyLabel, status: 'SUCCESS' }); + setProgress((prev) => + prev + ? prev.map((p, idx) => (idx === i ? { ...p, status: 'ok' } : p)) + : prev + ); + } + } catch (e) { + failCount += 1; + const msg = e instanceof Error ? e.message : String(e); + results.push({ op: item.op, key: item.keyLabel, status: 'FAILED', error: msg }); + setProgress((prev) => + prev + ? prev.map((p, idx) => (idx === i ? { ...p, status: 'fail', error: msg } : p)) + : prev + ); + } + } + + const status = + failCount === 0 ? 'SUCCESS' : failCount === plans.length ? 'FAILED' : 'PARTIAL_SUCCESS'; + if (runId) { + try { + await apiFinishDataMigrate(runId, { status, results }); + } catch { + /* history best-effort */ + } + } + + setApplying(false); + toast({ + tone: failCount === 0 ? 'success' : 'warning', + title: + failCount === 0 + ? `Migrated ${plans.length} row ops` + : `Migrated with ${failCount} failure(s)`, + body: `Destination: ${dest.label}. Snapshot + history saved.`, + actionButtonLabel: 'View history', + onAction: () => void openHistory(), + durationMs: 8_000, + }); + await onAfterMigrate?.(); + }; + + if (!canCompareReady(source, dest)) return null; + + return ( +
+
+ + + Data migrate + + + {source.label} → {dest.label} + + + {classification.inserts.length} insert · {classification.updates.length} update ·{' '} + {classification.deletes.length} delete + {selected.uncappedCount > DATA_MIGRATE_ROW_CAP + ? ` · capped ${DATA_MIGRATE_ROW_CAP}` + : ''} + + +
+ +
+ Keys + {source.columns.map((c) => ( + + ))} +
+ +
+ + + + + +
+ + {!editTarget.ok && ( +

+ Migrate needs a single-table SELECT with schema loaded on the destination. +

+ )} + + {progress && + createPortal( +
+
+ Data migrate progress + {!applying && ( + + )} +
+
    + {progress.map((p, i) => ( +
  • + + {p.status === 'running' ? '…' : p.status === 'ok' ? '✓' : p.status === 'fail' ? '✗' : '·'} + + {p.op} + + {p.keyLabel} + +
  • + ))} +
+
, + document.body + )} + + {historyOpen && + createPortal( +
setHistoryOpen(false)} + > +
e.stopPropagation()} + > +
+ Data migrate history + +
+
+
    + {historyRuns.map((r) => ( +
  • + +
  • + ))} + {historyRuns.length === 0 && ( +
  • No runs yet
  • + )} +
+
+ {historyDetail ? ( + <> +
+ Table {historyDetail.tableName} ·{' '} + {historyDetail.rowCount} ops · keys [{historyDetail.keyColumns.join(', ')}] +
+
+ Snapshot (pre-apply dest rows) +
+                          {historyDetail.snapshotJson || '(none)'}
+                        
+
+
+ Script +
+                          {historyDetail.script || '(none)'}
+                        
+
+
+ Results +
    + {historyDetail.results.map((r, i) => ( +
  • + {r.status} {r.op} {r.key} + {r.error ? ` — ${r.error}` : ''} +
  • + ))} +
+
+ + ) : ( +

Select a run

+ )} +
+
+
+
, + document.body + )} +
+ ); +}; + +function canCompareReady(source: DataMigrateGrid, dest: DataMigrateGrid): boolean { + return Boolean(source.columns.length && dest.columns.length); +} diff --git a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx index 70e5e9f8..7008fccd 100644 --- a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx @@ -25,6 +25,8 @@ import { type CellDiffKind, type GridDiffSummary, } from '../../lib/resultDataDiff'; +import { buildSampleBookmarks } from '../../lib/sqlEditorSamples'; +import { DataMigrateBar } from './DataMigrateBar'; import { usePeekGridCrud } from './usePeekGridCrud'; import { SQL_ICON_STROKE } from './sqlIconStyle'; @@ -725,6 +727,24 @@ const SideBySideStatementSection: React.FC<{ return { diffByConnection, badgeByConnection, legendBits }; }, [compareActive, okGrids, baselineId]); + const [destId, setDestId] = useState(''); + useEffect(() => { + if (!compareActive) return; + const others = okGrids.filter((g) => g.connectionId !== baselineId); + if (!destId || !others.some((g) => g.connectionId === destId)) { + setDestId(others[0]?.connectionId ?? ''); + } + }, [compareActive, okGrids, baselineId, destId]); + + const sourceGrid = okGrids.find((g) => g.connectionId === baselineId); + const destGrid = okGrids.find((g) => g.connectionId === destId); + + const insertServerBeamSample = () => { + const sample = buildSampleBookmarks().find((b) => b.id === 'sample-server-beam-chunked'); + if (!sample) return; + useSqlEditorStore.getState().setSql(sample.sql); + }; + return (
{compareOn && ( + )} {compareActive && ( )} + {compareActive && sourceGrid?.result.ok && destGrid?.result.ok && ( + onRefresh?.(destGrid.connectionId)} + onOpenServerBeamSample={insertServerBeamSample} + /> + )} x.key).join('|')}`} @@ -801,8 +863,8 @@ const SideBySideStatementSection: React.FC<{ /> {compareActive && (

- Rows align by index on this page — use the same ORDER BY on each server for a meaningful - compare. Column names match case-insensitively. + Cell colors align by row index; Data migrate matches rows by key columns (source → dest). + Use the same ORDER BY when scanning. Cap: 500 ops — larger sets use Server Beam.

)}
diff --git a/apps/web/src/frontend/lib/dataMigratePlans.test.ts b/apps/web/src/frontend/lib/dataMigratePlans.test.ts new file mode 100644 index 00000000..1c6ed3ea --- /dev/null +++ b/apps/web/src/frontend/lib/dataMigratePlans.test.ts @@ -0,0 +1,49 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it } from 'vitest'; +import { buildDataMigratePlans, buildDestSnapshotJson } from './dataMigratePlans'; +import type { ClassifiedRowDiff } from './resultRowDiff'; + +describe('buildDataMigratePlans', () => { + const cols = ['id', 'name']; + const ops: ClassifiedRowDiff[] = [ + { op: 'insert', keyLabel: 'id=3', sourceRow: [3, 'New'] }, + { + op: 'update', + keyLabel: 'id=1', + sourceRow: [1, 'Alice'], + destRow: [1, 'Bob'], + }, + { op: 'delete', keyLabel: 'id=4', destRow: [4, 'Gone'] }, + ]; + + it('builds insert/update/delete plans for sqlite', () => { + const { plans, errors } = buildDataMigratePlans({ + tableName: 'customers', + dialect: 'sqlite', + sourceColumns: cols, + destColumns: cols, + keyNames: ['id'], + ops, + includeIdentity: true, + identityColumns: new Set(['id']), + }); + expect(errors).toEqual([]); + expect(plans).toHaveLength(3); + expect(plans[0]!.plan.kind).toBe('insert'); + expect(plans[0]!.plan.sql.toLowerCase()).toContain('insert into'); + expect(plans[1]!.plan.kind).toBe('update'); + expect(plans[1]!.plan.sql.toLowerCase()).toContain('update'); + expect(plans[2]!.plan.kind).toBe('delete'); + expect(plans[2]!.plan.sql.toLowerCase()).toContain('delete from'); + }); + + it('snapshots dest rows for update/delete', () => { + const json = buildDestSnapshotJson({ destColumns: cols, ops }); + const parsed = JSON.parse(json) as { rows: unknown[] }; + expect(parsed.rows).toHaveLength(2); + }); +}); diff --git a/apps/web/src/frontend/lib/dataMigratePlans.ts b/apps/web/src/frontend/lib/dataMigratePlans.ts new file mode 100644 index 00000000..98bf593d --- /dev/null +++ b/apps/web/src/frontend/lib/dataMigratePlans.ts @@ -0,0 +1,160 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Turn classified row diffs into bound PeekWritePlan statements for apply. + */ + +import { + buildPeekDelete, + buildPeekInsert, + buildPeekUpdate, + type PeekKeyColumn, + type PeekWritePlan, +} from './rowDml'; +import type { ClassifiedRowDiff } from './resultRowDiff'; +import { keyColumnsForGrid } from './resultRowDiff'; + +export interface DataMigratePlanItem { + op: ClassifiedRowDiff['op']; + keyLabel: string; + plan: PeekWritePlan; +} + +function rowToValues(columns: string[], row: unknown[]): Record { + const out: Record = {}; + for (let i = 0; i < columns.length; i++) { + out[columns[i]!] = row[i] ?? null; + } + return out; +} + +/** + * Align dest-column order to source for UPDATE: original=dest values in source + * column order; draft=source values. + */ +function alignRowToColumns( + fromCols: string[], + fromRow: unknown[], + toCols: string[] +): unknown[] { + const idx = new Map(fromCols.map((c, i) => [c.toLowerCase(), i])); + return toCols.map((c) => { + const i = idx.get(c.toLowerCase()); + return i === undefined ? null : (fromRow[i] ?? null); + }); +} + +export function buildDataMigratePlans(opts: { + tableName: string; + dialect: string; + sourceColumns: string[]; + destColumns: string[]; + keyNames: string[]; + ops: ClassifiedRowDiff[]; + /** When true, include identity/autoincrement values on INSERT (preserve source IDs). */ + includeIdentity: boolean; + identityColumns: Set; +}): { plans: DataMigratePlanItem[]; errors: string[] } { + const { + tableName, + dialect, + sourceColumns, + destColumns, + keyNames, + ops, + includeIdentity, + identityColumns, + } = opts; + + const sourceKeys = keyColumnsForGrid(keyNames, sourceColumns); + const destKeys = keyColumnsForGrid(keyNames, destColumns); + const plans: DataMigratePlanItem[] = []; + const errors: string[] = []; + + for (const op of ops) { + if (op.op === 'insert') { + if (!op.sourceRow) { + errors.push(`insert ${op.keyLabel}: missing source row`); + continue; + } + const built = buildPeekInsert({ + tableName, + dialect, + values: rowToValues(sourceColumns, op.sourceRow), + // Empty skip-set when includeIdentity — keep source ID values. + identityColumns: includeIdentity ? undefined : identityColumns, + }); + if ('error' in built) { + errors.push(`insert ${op.keyLabel}: ${built.error}`); + continue; + } + plans.push({ op: 'insert', keyLabel: op.keyLabel, plan: built }); + continue; + } + + if (op.op === 'update') { + if (!op.sourceRow || !op.destRow) { + errors.push(`update ${op.keyLabel}: missing rows`); + continue; + } + // UPDATE runs on dest: WHERE uses dest keys; SET uses source values. + const originalAligned = alignRowToColumns(destColumns, op.destRow, sourceColumns); + const draftAligned = op.sourceRow; + const keysOnSource: PeekKeyColumn[] = sourceKeys; + const built = buildPeekUpdate({ + tableName, + dialect, + columns: sourceColumns, + originalRow: originalAligned, + draftRow: draftAligned, + keyColumns: keysOnSource, + }); + if ('error' in built) { + errors.push(`update ${op.keyLabel}: ${built.error}`); + continue; + } + plans.push({ op: 'update', keyLabel: op.keyLabel, plan: built }); + continue; + } + + // delete — from destination + if (!op.destRow) { + errors.push(`delete ${op.keyLabel}: missing dest row`); + continue; + } + const built = buildPeekDelete({ + tableName, + dialect, + columns: destColumns, + row: op.destRow, + keyColumns: destKeys, + }); + if ('error' in built) { + errors.push(`delete ${op.keyLabel}: ${built.error}`); + continue; + } + plans.push({ op: 'delete', keyLabel: op.keyLabel, plan: built }); + } + + return { plans, errors }; +} + +/** JSON snapshot of destination rows that will be affected (pre-apply). */ +export function buildDestSnapshotJson(opts: { + destColumns: string[]; + ops: ClassifiedRowDiff[]; +}): string { + const rows = opts.ops + .filter((o) => o.op === 'update' || o.op === 'delete') + .map((o) => { + const row = o.destRow ?? []; + const obj: Record = { _op: o.op, _key: o.keyLabel }; + opts.destColumns.forEach((c, i) => { + obj[c] = row[i] ?? null; + }); + return obj; + }); + return JSON.stringify({ columns: opts.destColumns, rows }, null, 2); +} diff --git a/apps/web/src/frontend/lib/resultRowDiff.test.ts b/apps/web/src/frontend/lib/resultRowDiff.test.ts new file mode 100644 index 00000000..c5bd7624 --- /dev/null +++ b/apps/web/src/frontend/lib/resultRowDiff.test.ts @@ -0,0 +1,102 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it } from 'vitest'; +import { + classifyRowsByKey, + DATA_MIGRATE_ROW_CAP, + selectMigrateOps, +} from './resultRowDiff'; + +describe('classifyRowsByKey', () => { + const cols = ['id', 'name', 'city']; + + it('classifies insert, update, and delete by key', () => { + const source = { + columns: cols, + rows: [ + [1, 'Alice', 'Denver'], + [2, 'Shared', 'Austin'], + [3, 'New', 'Boston'], + ], + }; + const dest = { + columns: cols, + rows: [ + [1, 'Bob', 'Denver'], + [2, 'Shared', 'Austin'], + [4, 'OnlyDest', 'X'], + ], + }; + const c = classifyRowsByKey({ source, dest, keyNames: ['id'] }); + expect(c.inserts).toHaveLength(1); + expect(c.inserts[0]!.keyLabel).toMatch(/id=3/); + expect(c.updates).toHaveLength(1); + expect(c.updates[0]!.keyLabel).toMatch(/id=1/); + expect(c.deletes).toHaveLength(1); + expect(c.deletes[0]!.keyLabel).toMatch(/id=4/); + expect(c.totalOps).toBe(3); + }); + + it('skips null keys', () => { + const source = { columns: cols, rows: [[null, 'a', 'b']] }; + const dest = { columns: cols, rows: [] }; + const c = classifyRowsByKey({ source, dest, keyNames: ['id'] }); + expect(c.inserts).toHaveLength(0); + expect(c.skippedNullKeys).toBe(1); + }); + + it('matches composite keys', () => { + const source = { + columns: ['a', 'b', 'v'], + rows: [ + [1, 'x', 10], + [1, 'y', 20], + ], + }; + const dest = { + columns: ['a', 'b', 'v'], + rows: [[1, 'x', 11]], + }; + const c = classifyRowsByKey({ source, dest, keyNames: ['a', 'b'] }); + expect(c.updates).toHaveLength(1); + expect(c.inserts).toHaveLength(1); + expect(c.deletes).toHaveLength(0); + }); +}); + +describe('selectMigrateOps', () => { + it('respects checkboxes and caps at 500', () => { + const inserts = Array.from({ length: 300 }, (_, i) => ({ + op: 'insert' as const, + keyLabel: `id=${i}`, + sourceRow: [i], + })); + const updates = Array.from({ length: 300 }, (_, i) => ({ + op: 'update' as const, + keyLabel: `id=${i + 1000}`, + sourceRow: [i], + destRow: [i], + })); + const classification = { + inserts, + updates, + deletes: [], + skippedNullKeys: 0, + totalOps: 600, + }; + const selected = selectMigrateOps( + classification, + { insert: true, update: true, delete: false }, + DATA_MIGRATE_ROW_CAP + ); + expect(selected.uncappedCount).toBe(600); + expect(selected.truncated).toBe(true); + expect(selected.ops).toHaveLength(DATA_MIGRATE_ROW_CAP); + expect(selected.ops.every((o) => o.op === 'insert' || o.op === 'update')).toBe( + true + ); + }); +}); diff --git a/apps/web/src/frontend/lib/resultRowDiff.ts b/apps/web/src/frontend/lib/resultRowDiff.ts new file mode 100644 index 00000000..9ca4828f --- /dev/null +++ b/apps/web/src/frontend/lib/resultRowDiff.ts @@ -0,0 +1,201 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Key-based row classification for data migrate (source → destination). + * Side-by-side cell tinting stays index-aligned; DML uses this classifier. + */ + +import { resultValuesEqual } from './resultDataDiff'; +import type { PeekKeyColumn } from './rowDml'; + +export const DATA_MIGRATE_ROW_CAP = 500; + +export type RowDiffOp = 'insert' | 'update' | 'delete'; + +export interface ResultGridLike { + columns: string[]; + rows: unknown[][]; +} + +export interface ClassifiedRowDiff { + op: RowDiffOp; + /** Composite key string for display / progress. */ + keyLabel: string; + /** Source row (insert/update) — undefined for delete. */ + sourceRow?: unknown[]; + /** Destination row (update/delete) — undefined for insert. */ + destRow?: unknown[]; +} + +export interface RowDiffClassification { + inserts: ClassifiedRowDiff[]; + updates: ClassifiedRowDiff[]; + deletes: ClassifiedRowDiff[]; + skippedNullKeys: number; + /** Total ops before cap. */ + totalOps: number; +} + +function colIndexMap(columns: string[]): Map { + const map = new Map(); + columns.forEach((c, i) => { + const k = c.toLowerCase(); + if (!map.has(k)) map.set(k, i); + }); + return map; +} + +/** Resolve key columns against a grid's column list. */ +export function keyColumnsForGrid( + keyNames: string[], + columns: string[] +): PeekKeyColumn[] { + const idx = colIndexMap(columns); + return keyNames.map((name) => ({ + name, + resultIndex: idx.get(name.toLowerCase()) ?? -1, + })); +} + +function rowKey( + row: unknown[], + keys: PeekKeyColumn[] +): { ok: true; key: string; label: string } | { ok: false } { + const parts: string[] = []; + const labels: string[] = []; + for (const k of keys) { + if (k.resultIndex < 0) return { ok: false }; + const v = row[k.resultIndex]; + if (v === null || v === undefined) return { ok: false }; + parts.push(`${k.name.toLowerCase()}=${String(v)}`); + labels.push(`${k.name}=${String(v)}`); + } + return { ok: true, key: parts.join('|'), label: labels.join(', ') }; +} + +function nonKeyColumnsDiffer( + sourceRow: unknown[], + destRow: unknown[], + sourceCols: string[], + destCols: string[], + keyNamesLower: Set +): boolean { + const destIdx = colIndexMap(destCols); + for (let i = 0; i < sourceCols.length; i++) { + const name = sourceCols[i]!; + if (keyNamesLower.has(name.toLowerCase())) continue; + const di = destIdx.get(name.toLowerCase()); + if (di === undefined) continue; + if (!resultValuesEqual(sourceRow[i], destRow[di])) return true; + } + return false; +} + +/** + * Classify rows for migrating **source → dest** by key columns. + * - insert: key in source only + * - update: key in both, non-key values differ + * - delete: key in dest only + */ +export function classifyRowsByKey(opts: { + source: ResultGridLike; + dest: ResultGridLike; + keyNames: string[]; +}): RowDiffClassification { + const { source, dest, keyNames } = opts; + const sourceKeys = keyColumnsForGrid(keyNames, source.columns); + const destKeys = keyColumnsForGrid(keyNames, dest.columns); + const keyNamesLower = new Set(keyNames.map((k) => k.toLowerCase())); + + if ( + sourceKeys.length === 0 || + sourceKeys.some((k) => k.resultIndex < 0) || + destKeys.some((k) => k.resultIndex < 0) + ) { + return { + inserts: [], + updates: [], + deletes: [], + skippedNullKeys: 0, + totalOps: 0, + }; + } + + const sourceMap = new Map(); + const destMap = new Map(); + let skippedNullKeys = 0; + + for (const row of source.rows) { + const k = rowKey(row, sourceKeys); + if (!k.ok) { + skippedNullKeys += 1; + continue; + } + if (!sourceMap.has(k.key)) sourceMap.set(k.key, { row, label: k.label }); + } + for (const row of dest.rows) { + const k = rowKey(row, destKeys); + if (!k.ok) { + skippedNullKeys += 1; + continue; + } + if (!destMap.has(k.key)) destMap.set(k.key, { row, label: k.label }); + } + + const inserts: ClassifiedRowDiff[] = []; + const updates: ClassifiedRowDiff[] = []; + const deletes: ClassifiedRowDiff[] = []; + + for (const [key, src] of sourceMap) { + const dst = destMap.get(key); + if (!dst) { + inserts.push({ op: 'insert', keyLabel: src.label, sourceRow: src.row }); + continue; + } + if ( + nonKeyColumnsDiffer( + src.row, + dst.row, + source.columns, + dest.columns, + keyNamesLower + ) + ) { + updates.push({ + op: 'update', + keyLabel: src.label, + sourceRow: src.row, + destRow: dst.row, + }); + } + } + for (const [key, dst] of destMap) { + if (sourceMap.has(key)) continue; + deletes.push({ op: 'delete', keyLabel: dst.label, destRow: dst.row }); + } + + return { + inserts, + updates, + deletes, + skippedNullKeys, + totalOps: inserts.length + updates.length + deletes.length, + }; +} + +/** Filter by enabled ops and apply the 500-row cap (stable order: insert, update, delete). */ +export function selectMigrateOps( + classification: RowDiffClassification, + enabled: { insert: boolean; update: boolean; delete: boolean }, + cap = DATA_MIGRATE_ROW_CAP +): { ops: ClassifiedRowDiff[]; truncated: boolean; uncappedCount: number } { + const all: ClassifiedRowDiff[] = []; + if (enabled.insert) all.push(...classification.inserts); + if (enabled.update) all.push(...classification.updates); + if (enabled.delete) all.push(...classification.deletes); + const uncappedCount = all.length; + if (all.length <= cap) return { ops: all, truncated: false, uncappedCount }; + return { ops: all.slice(0, cap), truncated: true, uncappedCount }; +} diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 6063dcc7..69863d6b 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -131,9 +131,16 @@ compare / migrate). It lives in the same local web UI you open with `foxschema`. 6. **Compare data across servers** — switch the results layout to **Side-by-side**, check two or more Destinations, and leave **Compare** on. Cells that differ from - the baseline connection are colored: amber (modified), rose (missing on the other - side), emerald (extra). Pick the baseline with **vs**. Rows align by index on the - current page, so use the same `ORDER BY` on each server. + the source connection are colored: amber (modified), rose (missing), emerald + (extra). Pick **source** (and **dest** when more than two). Cell tinting aligns by + row index — use the same `ORDER BY` when scanning. + +7. **Data migrate (≤500 row ops)** — under Compare, use **Data migrate** to push + source → destination with checkboxes for **Insert / Update / Delete**, optional + **Include identity / IDs** (preserve autoincrement values), and a live progress + panel. Fox snapshots affected destination rows and records the run under + **Data migrate history**. More than 500 ops shows a toast with Server Beam + instructions instead of applying. Tips: From f689fb8310af4008c4af57f211ea686ca39572ca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 23:55:26 +0000 Subject: [PATCH 2/4] feat(sql-editor): data migrate transactions, stop/continue, failed rows Apply ops on a dedicated connection with optional all-or-nothing transaction or continue-on-error (per-op tx). Progress and a failures list show each failed key and error; skipped ops are marked after Stop. Co-authored-by: huy.phan9 --- .../backend/api/data-migrate-execute.test.ts | 146 ++++++++++++ .../src/backend/api/data-migrate-execute.ts | 215 ++++++++++++++++++ apps/web/src/backend/api/routes.ts | 81 +++++++ apps/web/src/frontend/api/dataMigrateApi.ts | 34 +++ .../components/sql-editor/DataMigrateBar.tsx | 215 ++++++++++++------ docs/USER_GUIDE.md | 7 +- 6 files changed, 626 insertions(+), 72 deletions(-) create mode 100644 apps/web/src/backend/api/data-migrate-execute.test.ts create mode 100644 apps/web/src/backend/api/data-migrate-execute.ts diff --git a/apps/web/src/backend/api/data-migrate-execute.test.ts b/apps/web/src/backend/api/data-migrate-execute.test.ts new file mode 100644 index 00000000..30cb3b33 --- /dev/null +++ b/apps/web/src/backend/api/data-migrate-execute.test.ts @@ -0,0 +1,146 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { ConnectionFactory } from '@foxschema/db'; +import { executeDataMigrateOps } from './data-migrate-execute'; +import { getAdapter } from '@foxschema/db'; + +async function seedDb(dbPath: string): Promise { + // @ts-expect-error no type declarations for better-sqlite3 + const mod = (await import('better-sqlite3')) as { + default: new (path: string) => { exec(sql: string): void; close(): void }; + }; + const db = new mod.default(dbPath); + db.exec(` + CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT); + INSERT INTO customers (id, name) VALUES (1, 'Bob'); + INSERT INTO customers (id, name) VALUES (2, 'Shared'); + `); + db.close(); +} + +describe('executeDataMigrateOps', () => { + let dbPath: string; + + beforeEach(async () => { + await ConnectionFactory.closeAll().catch(() => {}); + dbPath = join(tmpdir(), `fox-data-migrate-${process.pid}-${Date.now()}.db`); + await seedDb(dbPath); + }); + + afterEach(async () => { + await ConnectionFactory.closeAll().catch(() => {}); + rmSync(dbPath, { force: true }); + }); + + it('atomic transaction rolls back all ops on failure', async () => { + const out = await executeDataMigrateOps( + 'sqlite', + { connectionString: dbPath }, + undefined, + [ + { + op: 'update', + key: 'id=1', + sql: `UPDATE customers SET name = 'Alice' WHERE id = 1`, + }, + { + op: 'insert', + key: 'id=bad', + sql: `INSERT INTO missing_table (id) VALUES (9)`, + }, + { + op: 'insert', + key: 'id=3', + sql: `INSERT INTO customers (id, name) VALUES (3, 'New')`, + }, + ], + { useTransaction: true, continueOnError: false } + ); + expect(out.rolledBack).toBe(true); + expect(out.failCount).toBe(1); + expect(out.results.map((r) => r.status)).toEqual(['SUCCESS', 'FAILED', 'SKIPPED']); + + await ConnectionFactory.closeAll().catch(() => {}); + const conn = await ConnectionFactory.create('sqlite', { connectionString: dbPath }); + try { + const rows = await getAdapter('sqlite').query<{ name: string }>( + conn, + 'SELECT name FROM customers WHERE id = 1', + [] + ); + expect(rows[0]?.name).toBe('Bob'); + } finally { + await ConnectionFactory.close('sqlite', conn); + } + }); + + it('continueOnError keeps going and commits successful ops', async () => { + const out = await executeDataMigrateOps( + 'sqlite', + { connectionString: dbPath }, + undefined, + [ + { + op: 'update', + key: 'id=1', + sql: `UPDATE customers SET name = 'Alice' WHERE id = 1`, + }, + { + op: 'insert', + key: 'id=bad', + sql: `INSERT INTO missing_table (id) VALUES (9)`, + }, + { + op: 'insert', + key: 'id=3', + sql: `INSERT INTO customers (id, name) VALUES (3, 'New')`, + }, + ], + { useTransaction: false, continueOnError: true } + ); + expect(out.failCount).toBe(1); + expect(out.results.map((r) => r.status)).toEqual(['SUCCESS', 'FAILED', 'SUCCESS']); + + await ConnectionFactory.closeAll().catch(() => {}); + const conn = await ConnectionFactory.create('sqlite', { connectionString: dbPath }); + try { + const rows = await getAdapter('sqlite').query<{ name: string }>( + conn, + 'SELECT name FROM customers ORDER BY id', + [] + ); + expect(rows.map((r) => r.name)).toEqual(['Alice', 'Shared', 'New']); + } finally { + await ConnectionFactory.close('sqlite', conn); + } + }); + + it('stop without transaction skips remaining after first failure', async () => { + const out = await executeDataMigrateOps( + 'sqlite', + { connectionString: dbPath }, + undefined, + [ + { + op: 'insert', + key: 'id=bad', + sql: `INSERT INTO missing_table (id) VALUES (9)`, + }, + { + op: 'update', + key: 'id=1', + sql: `UPDATE customers SET name = 'Alice' WHERE id = 1`, + }, + ], + { useTransaction: false, continueOnError: false } + ); + expect(out.results.map((r) => r.status)).toEqual(['FAILED', 'SKIPPED']); + }); +}); diff --git a/apps/web/src/backend/api/data-migrate-execute.ts b/apps/web/src/backend/api/data-migrate-execute.ts new file mode 100644 index 00000000..9fb1e95a --- /dev/null +++ b/apps/web/src/backend/api/data-migrate-execute.ts @@ -0,0 +1,215 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Apply data-migrate row ops on one dedicated connection with optional + * transaction wrapping (same patterns as Schema Sync MigrationModule). + */ +import { ConnectionFactory, getAdapter, type ConnectionOptions } from '@foxschema/db'; + +export type DataMigrateOpKind = 'insert' | 'update' | 'delete'; + +export interface DataMigrateExecOp { + op: DataMigrateOpKind; + key: string; + sql: string; + params?: unknown[]; +} + +export type DataMigrateExecEvent = + | { type: 'start'; total: number } + | { + type: 'op'; + index: number; + op: DataMigrateOpKind; + key: string; + status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'SKIPPED'; + error?: string; + } + | { + type: 'done'; + success: boolean; + rolledBack: boolean; + failCount: number; + error?: string; + }; + +export interface DataMigrateExecResult { + results: Array<{ + op: DataMigrateOpKind; + key: string; + status: 'SUCCESS' | 'FAILED' | 'SKIPPED'; + error?: string; + }>; + rolledBack: boolean; + failCount: number; +} + +/** + * - useTransaction + !continueOnError: one transaction, first failure → rollback, rest SKIPPED + * - continueOnError: each op in its own transaction (failed op rolls back only itself) + * - !useTransaction + !continueOnError: no outer tx; stop after first failure (rest SKIPPED) + * - !useTransaction + continueOnError: no tx; keep going on failures + */ +export async function executeDataMigrateOps( + dialect: string, + option: ConnectionOptions, + schema: string | undefined, + ops: DataMigrateExecOp[], + opts: { useTransaction: boolean; continueOnError: boolean }, + onEvent?: (e: DataMigrateExecEvent) => void +): Promise { + const adapter = getAdapter(dialect); + const conn = await ConnectionFactory.create(dialect, option, { pooled: false }); + const results: DataMigrateExecResult['results'] = []; + let failCount = 0; + let rolledBack = false; + + const emit = (e: DataMigrateExecEvent) => onEvent?.(e); + + try { + if (schema?.trim()) { + await adapter.setCurrentSchema(conn, schema.trim()); + } + emit({ type: 'start', total: ops.length }); + + if (opts.useTransaction && !opts.continueOnError) { + await adapter.beginTransaction(conn); + try { + for (let i = 0; i < ops.length; i++) { + const item = ops[i]!; + emit({ type: 'op', index: i, op: item.op, key: item.key, status: 'RUNNING' }); + try { + await adapter.query(conn, item.sql.replace(/;\s*$/, ''), item.params ?? []); + results.push({ op: item.op, key: item.key, status: 'SUCCESS' }); + emit({ type: 'op', index: i, op: item.op, key: item.key, status: 'SUCCESS' }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + failCount += 1; + results.push({ op: item.op, key: item.key, status: 'FAILED', error: message }); + emit({ + type: 'op', + index: i, + op: item.op, + key: item.key, + status: 'FAILED', + error: message, + }); + for (let j = i + 1; j < ops.length; j++) { + const skipped = ops[j]!; + results.push({ + op: skipped.op, + key: skipped.key, + status: 'SKIPPED', + error: 'Stopped after earlier failure (transaction rolled back)', + }); + emit({ + type: 'op', + index: j, + op: skipped.op, + key: skipped.key, + status: 'SKIPPED', + error: 'Stopped after earlier failure (transaction rolled back)', + }); + } + throw err; + } + } + await adapter.commitTransaction(conn); + emit({ type: 'done', success: true, rolledBack: false, failCount: 0 }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + try { + await adapter.rollbackTransaction(conn); + rolledBack = true; + } catch (rollbackErr) { + console.error('Data migrate rollback failed:', rollbackErr); + } + emit({ + type: 'done', + success: false, + rolledBack, + failCount, + error: message, + }); + } + return { results, rolledBack, failCount }; + } + + // Per-op transaction (continueOnError) or autocommit (no outer transaction). + for (let i = 0; i < ops.length; i++) { + const item = ops[i]!; + emit({ type: 'op', index: i, op: item.op, key: item.key, status: 'RUNNING' }); + try { + if (opts.useTransaction || opts.continueOnError) { + // continueOnError always uses per-op tx; useTransaction+continue also. + await adapter.beginTransaction(conn); + } + await adapter.query(conn, item.sql.replace(/;\s*$/, ''), item.params ?? []); + if (opts.useTransaction || opts.continueOnError) { + await adapter.commitTransaction(conn); + } + results.push({ op: item.op, key: item.key, status: 'SUCCESS' }); + emit({ type: 'op', index: i, op: item.op, key: item.key, status: 'SUCCESS' }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + failCount += 1; + if (opts.useTransaction || opts.continueOnError) { + try { + await adapter.rollbackTransaction(conn); + } catch (rollbackErr) { + console.error(`Data migrate rollback of ${item.key} failed:`, rollbackErr); + } + } + results.push({ op: item.op, key: item.key, status: 'FAILED', error: message }); + emit({ + type: 'op', + index: i, + op: item.op, + key: item.key, + status: 'FAILED', + error: message, + }); + + if (!opts.continueOnError) { + for (let j = i + 1; j < ops.length; j++) { + const skipped = ops[j]!; + results.push({ + op: skipped.op, + key: skipped.key, + status: 'SKIPPED', + error: 'Stopped after earlier failure', + }); + emit({ + type: 'op', + index: j, + op: skipped.op, + key: skipped.key, + status: 'SKIPPED', + error: 'Stopped after earlier failure', + }); + } + emit({ + type: 'done', + success: false, + rolledBack: false, + failCount, + error: message, + }); + return { results, rolledBack: false, failCount }; + } + } + } + + emit({ + type: 'done', + success: failCount === 0, + rolledBack: false, + failCount, + }); + return { results, rolledBack: false, failCount }; + } finally { + await ConnectionFactory.close(dialect, conn); + } +} diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index 7cf57b3e..143af751 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -32,6 +32,7 @@ import { type DataMigrateOpResult, type DataMigrateRunStatus, } from '../modules/data-migrate-history.module'; +import { executeDataMigrateOps, type DataMigrateExecOp } from './data-migrate-execute'; import { AppSettingsStore } from '../modules/app-settings.module'; import { rateLimit } from './rate-limit'; import { @@ -789,6 +790,86 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt res.status(removed ? 200 : 404).json({ ok: removed }); }); + // --- Data migrate apply (transaction / continue-on-error) ---------------- + router.post( + '/data-migrate/execute', + requirePermissions('editor.dml'), + sqlExecuteLimiter, + async (req: Request, res: Response) => { + const body = req.body as ConnectionRef & { + ops?: unknown; + useTransaction?: unknown; + continueOnError?: unknown; + }; + const authed = req as AuthedRequest; + if (!Array.isArray(body.ops) || body.ops.length === 0) { + res.status(400).json({ error: 'ops[] is required.' }); + return; + } + if (body.ops.length > 500) { + res.status(400).json({ error: 'At most 500 ops per data migrate.' }); + return; + } + const ops: DataMigrateExecOp[] = []; + const needed = new Set(['editor.dml']); + for (const raw of body.ops) { + if (!raw || typeof raw !== 'object') { + res.status(400).json({ error: 'Each op must be an object.' }); + return; + } + const o = raw as Record; + if (o.op !== 'insert' && o.op !== 'update' && o.op !== 'delete') { + res.status(400).json({ error: 'op must be insert, update, or delete.' }); + return; + } + if (typeof o.key !== 'string' || typeof o.sql !== 'string' || !o.sql.trim()) { + res.status(400).json({ error: 'Each op needs key and sql.' }); + return; + } + if (o.params !== undefined && !Array.isArray(o.params)) { + res.status(400).json({ error: 'op.params must be an array when set.' }); + return; + } + needed.add(DATAGRID_ACTION_PERMISSION[o.op]); + ops.push({ + op: o.op, + key: o.key, + sql: o.sql, + params: Array.isArray(o.params) ? o.params : [], + }); + } + if (denyUnless(authed, res, ...needed)) return; + + let resolved; + try { + resolved = await resolveRef(authed.userId, body); + } catch (error: unknown) { + res.status(400).json({ + error: error instanceof Error ? error.message : 'Invalid connection', + }); + return; + } + + try { + const out = await executeDataMigrateOps( + resolved.dialect, + resolved.option, + resolved.schema, + ops, + { + useTransaction: body.useTransaction !== false, + continueOnError: Boolean(body.continueOnError), + } + ); + res.json(out); + } catch (error: unknown) { + res.status(500).json({ + error: error instanceof Error ? error.message : 'Data migrate failed', + }); + } + } + ); + // --- Data migrate history (SQL Editor side-by-side row ops) --------------- router.get('/data-migrations', requirePermissions('editor.dml'), async (req: Request, res: Response) => { res.json({ runs: await dataMigrateHistory.list((req as AuthedRequest).userId!) }); diff --git a/apps/web/src/frontend/api/dataMigrateApi.ts b/apps/web/src/frontend/api/dataMigrateApi.ts index a08978f4..ff50aa2c 100644 --- a/apps/web/src/frontend/api/dataMigrateApi.ts +++ b/apps/web/src/frontend/api/dataMigrateApi.ts @@ -99,3 +99,37 @@ export async function apiGetDataMigration(id: string): Promise { await request(`/data-migrations/${id}`, { method: 'DELETE' }); } + +export interface DataMigrateExecOp { + op: 'insert' | 'update' | 'delete'; + key: string; + sql: string; + params?: unknown[]; +} + +export interface DataMigrateExecOutcome { + results: DataMigrateOpResult[]; + rolledBack: boolean; + failCount: number; +} + +/** Apply row ops on the destination with optional transaction / continue-on-error. */ +export async function apiExecuteDataMigrate( + ref: { + connectionId: string; + password?: string; + schema?: string; + }, + ops: DataMigrateExecOp[], + opts: { useTransaction: boolean; continueOnError: boolean } +): Promise { + return request('/data-migrate/execute', { + method: 'POST', + body: JSON.stringify({ + ...ref, + ops, + useTransaction: opts.useTransaction, + continueOnError: opts.continueOnError, + }), + }); +} diff --git a/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx b/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx index b8bc5598..c5aea6d9 100644 --- a/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx @@ -9,8 +9,8 @@ import React, { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { ArrowRightLeft, History, Loader2, X } from 'lucide-react'; -import { executeSql } from '../../api/sqlApi'; import { + apiExecuteDataMigrate, apiFinishDataMigrate, apiGetDataMigration, apiListDataMigrations, @@ -47,7 +47,7 @@ export interface DataMigrateGrid { type ProgressItem = { keyLabel: string; op: ClassifiedRowDiff['op']; - status: 'pending' | 'running' | 'ok' | 'fail'; + status: 'pending' | 'running' | 'ok' | 'fail' | 'skipped'; error?: string; }; @@ -97,8 +97,13 @@ export const DataMigrateBar: React.FC = ({ const [doUpdate, setDoUpdate] = useState(true); const [doDelete, setDoDelete] = useState(false); const [includeIdentity, setIncludeIdentity] = useState(false); + /** One transaction for the whole batch (Stop mode). Off with Continue = per-op commits. */ + const [useTransaction, setUseTransaction] = useState(true); + /** Continue = skip failures; Stop = abort (rollback if transaction on). */ + const [continueOnError, setContinueOnError] = useState(false); const [applying, setApplying] = useState(false); const [progress, setProgress] = useState(null); + const [failedSummary, setFailedSummary] = useState([]); const [historyOpen, setHistoryOpen] = useState(false); const [historyRuns, setHistoryRuns] = useState([]); const [historyDetail, setHistoryDetail] = useState(null); @@ -212,9 +217,13 @@ export const DataMigrateBar: React.FC = ({ destColumns: dest.columns, ops: selected.ops, }); - const script = plans.map((p) => `-- ${p.op} ${p.keyLabel}\n${p.plan.displaySql};`).join('\n\n'); + const script = [ + `-- useTransaction=${useTransaction} continueOnError=${continueOnError}`, + ...plans.map((p) => `-- ${p.op} ${p.keyLabel}\n${p.plan.displaySql};`), + ].join('\n\n'); setApplying(true); + setFailedSummary([]); setProgress( plans.map((p) => ({ keyLabel: p.keyLabel, @@ -247,67 +256,66 @@ export const DataMigrateBar: React.FC = ({ }); } - const results: DataMigrateOpResult[] = []; + let results: DataMigrateOpResult[] = []; let failCount = 0; + let rolledBack = false; - for (let i = 0; i < plans.length; i++) { - const item = plans[i]!; - setProgress((prev) => - prev - ? prev.map((p, idx) => (idx === i ? { ...p, status: 'running' } : p)) - : prev + try { + // Mark all running while the server applies (one connection / optional tx). + setProgress((prev) => prev?.map((p) => ({ ...p, status: 'running' })) ?? prev); + const out = await apiExecuteDataMigrate( + { + connectionId: dest.connectionId, + password: sessionPasswords[dest.connectionId] || undefined, + schema: destConn?.schema?.trim() || undefined, + }, + plans.map((p) => ({ + op: p.op, + key: p.keyLabel, + sql: p.plan.sql, + params: p.plan.params, + })), + { useTransaction, continueOnError } ); - try { - const { results: execResults } = await executeSql( - { - connectionId: dest.connectionId, - password: sessionPasswords[dest.connectionId] || undefined, - schema: destConn?.schema?.trim() || undefined, - }, - [item.plan.sql], - undefined, - undefined, - item.plan.params.length ? [item.plan.params] : undefined, - { datagridAction: item.op } - ); - const failed = execResults.find((r) => !r.ok); - if (failed && !failed.ok) { - failCount += 1; - results.push({ - op: item.op, - key: item.keyLabel, - status: 'FAILED', - error: failed.error, - }); - setProgress((prev) => - prev - ? prev.map((p, idx) => - idx === i ? { ...p, status: 'fail', error: failed.error } : p - ) - : prev - ); - } else { - results.push({ op: item.op, key: item.keyLabel, status: 'SUCCESS' }); - setProgress((prev) => - prev - ? prev.map((p, idx) => (idx === i ? { ...p, status: 'ok' } : p)) - : prev - ); - } - } catch (e) { - failCount += 1; - const msg = e instanceof Error ? e.message : String(e); - results.push({ op: item.op, key: item.keyLabel, status: 'FAILED', error: msg }); - setProgress((prev) => - prev - ? prev.map((p, idx) => (idx === i ? { ...p, status: 'fail', error: msg } : p)) - : prev - ); - } + results = out.results; + failCount = out.failCount; + rolledBack = out.rolledBack; + setProgress( + results.map((r) => ({ + keyLabel: r.key, + op: r.op, + status: + r.status === 'SUCCESS' ? 'ok' : r.status === 'SKIPPED' ? 'skipped' : 'fail', + error: r.error, + })) + ); + setFailedSummary(results.filter((r) => r.status === 'FAILED')); + } catch (e) { + failCount = plans.length; + const msg = e instanceof Error ? e.message : String(e); + results = plans.map((p) => ({ + op: p.op, + key: p.keyLabel, + status: 'FAILED' as const, + error: msg, + })); + setProgress( + plans.map((p) => ({ + keyLabel: p.keyLabel, + op: p.op, + status: 'fail' as const, + error: msg, + })) + ); + setFailedSummary(results); } const status = - failCount === 0 ? 'SUCCESS' : failCount === plans.length ? 'FAILED' : 'PARTIAL_SUCCESS'; + failCount === 0 + ? 'SUCCESS' + : failCount === plans.length || rolledBack + ? 'FAILED' + : 'PARTIAL_SUCCESS'; if (runId) { try { await apiFinishDataMigrate(runId, { status, results }); @@ -317,18 +325,29 @@ export const DataMigrateBar: React.FC = ({ } setApplying(false); + const failedKeys = results + .filter((r) => r.status === 'FAILED') + .map((r) => `${r.op} ${r.key}`) + .slice(0, 5); toast({ tone: failCount === 0 ? 'success' : 'warning', title: failCount === 0 ? `Migrated ${plans.length} row ops` - : `Migrated with ${failCount} failure(s)`, - body: `Destination: ${dest.label}. Snapshot + history saved.`, + : rolledBack + ? `Rolled back — ${failCount} failure(s)` + : `Finished with ${failCount} failure(s)`, + body: + failCount === 0 + ? `Destination: ${dest.label}. Snapshot + history saved.` + : `Failed: ${failedKeys.join('; ')}${ + results.filter((r) => r.status === 'FAILED').length > 5 ? '…' : '' + }. ${rolledBack ? 'Transaction rolled back. ' : ''}See progress / history.`, actionButtonLabel: 'View history', onAction: () => void openHistory(), - durationMs: 8_000, + durationMs: 10_000, }); - await onAfterMigrate?.(); + if (!rolledBack) await onAfterMigrate?.(); }; if (!canCompareReady(source, dest)) return null; @@ -422,6 +441,32 @@ export const DataMigrateBar: React.FC = ({ /> Include identity / IDs + +