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}`
+ : ''}
+
+
void openHistory()}
+ className="inline-flex items-center gap-1 text-slate-500 hover:text-cyan-400"
+ >
+ History
+
+
+
+
+ Keys
+ {source.columns.map((c) => (
+
+ k.toLowerCase() === c.toLowerCase())}
+ onChange={() => toggleKey(c)}
+ className="rounded border-slate-600"
+ />
+ {c}
+
+ ))}
+
+
+
+
+ setDoInsert(e.target.checked)}
+ className="rounded border-slate-600"
+ />
+ Insert ({classification.inserts.length})
+
+
+ setDoUpdate(e.target.checked)}
+ className="rounded border-slate-600"
+ />
+ Update ({classification.updates.length})
+
+
+ setDoDelete(e.target.checked)}
+ className="rounded border-slate-600"
+ />
+ Delete ({classification.deletes.length})
+
+
+ setIncludeIdentity(e.target.checked)}
+ className="rounded border-slate-600"
+ />
+ Include identity / IDs
+
+ DATA_MIGRATE_ROW_CAP
+ }
+ onClick={() => void apply()}
+ className="ml-auto px-2 py-0.5 rounded bg-cyan-700/40 border border-cyan-500/40 text-cyan-200 hover:bg-cyan-600/50 disabled:opacity-40 disabled:cursor-not-allowed"
+ title={
+ selected.uncappedCount > DATA_MIGRATE_ROW_CAP
+ ? `Over ${DATA_MIGRATE_ROW_CAP} ops — use Server Beam`
+ : undefined
+ }
+ >
+ {applying ? (
+
+ Migrating…
+
+ ) : selected.uncappedCount > DATA_MIGRATE_ROW_CAP ? (
+ `Over ${DATA_MIGRATE_ROW_CAP} — Server Beam`
+ ) : (
+ `Migrate ${selected.uncappedCount} ops`
+ )}
+
+
+
+ {!editTarget.ok && (
+
+ Migrate needs a single-table SELECT with schema loaded on the destination.
+
+ )}
+
+ {progress &&
+ createPortal(
+
+
+ Data migrate progress
+ {!applying && (
+ setProgress(null)}
+ className="text-slate-500 hover:text-slate-200"
+ >
+
+
+ )}
+
+
+ {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
+ setHistoryOpen(false)} className="text-slate-500">
+
+
+
+
+
+ {historyRuns.map((r) => (
+
+ void apiGetDataMigration(r.id).then(setHistoryDetail)}
+ >
+ {r.status}
+ {r.tableName || r.targetHost || '—'}
+ {new Date(r.startedAt).toLocaleString()}
+
+
+ ))}
+ {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 && (
- vs
+ source
)}
+ {compareActive && okGrids.length > 2 && (
+
+ dest
+ setDestId(e.target.value)}
+ className="bg-slate-900 border border-slate-700 rounded px-1.5 py-0.5 text-[10px] text-slate-200 max-w-[12rem]"
+ >
+ {okGrids
+ .filter((g) => g.connectionId !== baselineId)
+ .map((g) => (
+
+ {g.label}
+
+ ))}
+
+
+ )}
{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
+
+ setUseTransaction(e.target.checked)}
+ className="rounded border-slate-600"
+ />
+ Transaction
+
+
+ setContinueOnError(e.target.checked)}
+ className="rounded border-slate-600"
+ />
+ {continueOnError ? 'Continue on error' : 'Stop on error'}
+
= ({
)}
+ {failedSummary.length > 0 && (
+
+
+ Failed records ({failedSummary.length})
+
+
+ {failedSummary.map((r, i) => (
+
+ {r.op} {r.key}
+ {r.error ? ` — ${r.error}` : ''}
+
+ ))}
+
+
+ )}
+
{progress &&
createPortal(
@@ -477,23 +541,36 @@ export const DataMigrateBar: React.FC
= ({
{progress.map((p, i) => (
-
+
- {p.status === 'running' ? '…' : p.status === 'ok' ? '✓' : p.status === 'fail' ? '✗' : '·'}
+ {p.status === 'running'
+ ? '…'
+ : p.status === 'ok'
+ ? '✓'
+ : p.status === 'fail'
+ ? '✗'
+ : p.status === 'skipped'
+ ? '–'
+ : '·'}
{p.op}
-
+
{p.keyLabel}
+ {p.error ? (
+ {p.error}
+ ) : null}
))}
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index 69863d6b..3af06700 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -137,9 +137,10 @@ compare / migrate). It lives in the same local web UI you open with `foxschema`.
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
+ **Include identity / IDs**, **Transaction** (all-or-nothing when Stop is on), and
+ **Stop on error** / **Continue on error**. Progress lists each row; failures show
+ the key and error. 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 555cd336ba7cc283abe57a3ed88b180c2cc9a6bf Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 7 Aug 2026 00:18:48 +0000
Subject: [PATCH 3/4] feat(sql-editor): skip trigger/audit columns in compare
and data migrate
createdAt, updatedBy, and similar columns differ across databases even when
business rows match. Detect them by name heuristic, ignore in Compare
highlights and UPDATE detection by default, and omit them from migrate
INSERT/UPDATE so destination triggers can populate values.
Co-authored-by: huy.phan9
---
.../components/sql-editor/DataMigrateBar.tsx | 7 +-
.../components/sql-editor/ResultsPanel.tsx | 53 +++++++++++++--
.../src/frontend/lib/dataMigratePlans.test.ts | 39 +++++++++++
apps/web/src/frontend/lib/dataMigratePlans.ts | 39 +++++++++--
.../src/frontend/lib/resultDataDiff.test.ts | 19 ++++++
apps/web/src/frontend/lib/resultDataDiff.ts | 15 +++--
.../src/frontend/lib/resultRowDiff.test.ts | 24 +++++++
apps/web/src/frontend/lib/resultRowDiff.ts | 17 +++--
.../lib/triggerManagedColumns.test.ts | 43 ++++++++++++
.../src/frontend/lib/triggerManagedColumns.ts | 65 +++++++++++++++++++
docs/USER_GUIDE.md | 12 ++--
11 files changed, 307 insertions(+), 26 deletions(-)
create mode 100644 apps/web/src/frontend/lib/triggerManagedColumns.test.ts
create mode 100644 apps/web/src/frontend/lib/triggerManagedColumns.ts
diff --git a/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx b/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx
index c5aea6d9..056a3f12 100644
--- a/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx
+++ b/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx
@@ -55,6 +55,8 @@ interface Props {
statementIndex: number;
source: DataMigrateGrid;
dest: DataMigrateGrid;
+ /** Trigger/audit columns excluded from UPDATE detection and INSERT/UPDATE SET. */
+ ignoreColumns?: string[];
onAfterMigrate?: () => void;
onOpenServerBeamSample?: () => void;
}
@@ -63,6 +65,7 @@ export const DataMigrateBar: React.FC = ({
statementIndex,
source,
dest,
+ ignoreColumns = [],
onAfterMigrate,
onOpenServerBeamSample,
}) => {
@@ -119,8 +122,9 @@ export const DataMigrateBar: React.FC = ({
source: { columns: source.columns, rows: source.rows },
dest: { columns: dest.columns, rows: dest.rows },
keyNames,
+ ignoreColumns,
}),
- [source.columns, source.rows, dest.columns, dest.rows, keyNames]
+ [source.columns, source.rows, dest.columns, dest.rows, keyNames, ignoreColumns]
);
const selected = useMemo(
@@ -203,6 +207,7 @@ export const DataMigrateBar: React.FC = ({
ops: selected.ops,
includeIdentity,
identityColumns: editability.identityColumns,
+ ignoreColumns,
});
if (errors.length) {
toast({
diff --git a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx
index 7008fccd..56b4f4c3 100644
--- a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx
+++ b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx
@@ -25,6 +25,7 @@ import {
type CellDiffKind,
type GridDiffSummary,
} from '../../lib/resultDataDiff';
+import { detectTriggerManagedColumns } from '../../lib/triggerManagedColumns';
import { buildSampleBookmarks } from '../../lib/sqlEditorSamples';
import { DataMigrateBar } from './DataMigrateBar';
import { usePeekGridCrud } from './usePeekGridCrud';
@@ -639,6 +640,8 @@ const SideBySideStatementSection: React.FC<{
);
const canCompare = okGrids.length >= 2;
const [compareOn, setCompareOn] = useState(true);
+ /** Skip createdAt / updatedBy / etc. — values differ across DBs even when rows match. */
+ const [skipTriggerCols, setSkipTriggerCols] = useState(true);
const [baselineId, setBaselineId] = useState('');
useEffect(() => {
@@ -650,6 +653,16 @@ const SideBySideStatementSection: React.FC<{
const compareActive = canCompare && compareOn && Boolean(baselineId);
+ const triggerIgnoreColumns = useMemo(() => {
+ if (!skipTriggerCols) return [] as string[];
+ const names = new Set();
+ for (const g of okGrids) {
+ if (!g.result.ok) continue;
+ for (const c of detectTriggerManagedColumns(g.result.columns)) names.add(c);
+ }
+ return [...names];
+ }, [skipTriggerCols, okGrids]);
+
const { diffByConnection, badgeByConnection, legendBits } = useMemo(() => {
const diffByConnection: Record = {};
const badgeByConnection: Record = {};
@@ -672,13 +685,21 @@ const SideBySideStatementSection: React.FC<{
let totalExtra = 0;
const missingCols = new Set();
const extraCols = new Set();
+ const ignoreOpts =
+ triggerIgnoreColumns.length > 0
+ ? { ignoreColumns: triggerIgnoreColumns }
+ : undefined;
for (const g of okGrids) {
if (g.connectionId === baselineId || !g.result.ok) continue;
- const pair = compareResultGrids(baselineGrid, {
- columns: g.result.columns,
- rows: g.result.rows,
- });
+ const pair = compareResultGrids(
+ baselineGrid,
+ {
+ columns: g.result.columns,
+ rows: g.result.rows,
+ },
+ ignoreOpts
+ );
// Merge baseline highlights across all others (union of diffs).
const prev = diffByConnection[baselineId];
if (!prev) {
@@ -722,10 +743,13 @@ const SideBySideStatementSection: React.FC<{
if (extraCols.size > 0) {
legendBits.push(`cols only in other: ${[...extraCols].join(', ')}`);
}
+ if (triggerIgnoreColumns.length > 0) {
+ legendBits.push(`skipping ${triggerIgnoreColumns.join(', ')}`);
+ }
if (legendBits.length === 0) legendBits.push('grids match on this page');
return { diffByConnection, badgeByConnection, legendBits };
- }, [compareActive, okGrids, baselineId]);
+ }, [compareActive, okGrids, baselineId, triggerIgnoreColumns]);
const [destId, setDestId] = useState('');
useEffect(() => {
@@ -787,6 +811,21 @@ const SideBySideStatementSection: React.FC<{
)}
+ {compareOn && (
+
+ setSkipTriggerCols(e.target.checked)}
+ className="rounded border-slate-600"
+ />
+ Skip trigger cols
+
+ )}
{compareActive && okGrids.length > 2 && (
dest
@@ -847,6 +886,7 @@ const SideBySideStatementSection: React.FC<{
rows: destGrid.result.rows,
statementSql: destGrid.statementSql,
}}
+ ignoreColumns={triggerIgnoreColumns}
onAfterMigrate={() => onRefresh?.(destGrid.connectionId)}
onOpenServerBeamSample={insertServerBeamSample}
/>
@@ -864,7 +904,8 @@ const SideBySideStatementSection: React.FC<{
{compareActive && (
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.
+ Skip trigger cols ignores createdAt / updatedBy and similar. 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
index 1c6ed3ea..6745a581 100644
--- a/apps/web/src/frontend/lib/dataMigratePlans.test.ts
+++ b/apps/web/src/frontend/lib/dataMigratePlans.test.ts
@@ -46,4 +46,43 @@ describe('buildDataMigratePlans', () => {
const parsed = JSON.parse(json) as { rows: unknown[] };
expect(parsed.rows).toHaveLength(2);
});
+
+ it('omits ignored trigger columns from INSERT and UPDATE SQL', () => {
+ const auditCols = ['id', 'name', 'createdAt', 'updatedBy'];
+ const auditOps: ClassifiedRowDiff[] = [
+ {
+ op: 'insert',
+ keyLabel: 'id=9',
+ sourceRow: [9, 'New', '2020-01-01', 'src'],
+ },
+ {
+ op: 'update',
+ keyLabel: 'id=1',
+ sourceRow: [1, 'Alice', '2020-01-01', 'src'],
+ destRow: [1, 'Bob', '2024-01-01', 'dst'],
+ },
+ ];
+ const { plans, errors } = buildDataMigratePlans({
+ tableName: 'customers',
+ dialect: 'sqlite',
+ sourceColumns: auditCols,
+ destColumns: auditCols,
+ keyNames: ['id'],
+ ops: auditOps,
+ includeIdentity: true,
+ identityColumns: new Set(['id']),
+ ignoreColumns: ['createdAt', 'updatedBy'],
+ });
+ expect(errors).toEqual([]);
+ expect(plans).toHaveLength(2);
+ const insertSql = plans[0]!.plan.sql.toLowerCase();
+ const updateSql = plans[1]!.plan.sql.toLowerCase();
+ expect(insertSql).not.toContain('createdat');
+ expect(insertSql).not.toContain('updatedby');
+ expect(updateSql).not.toContain('createdat');
+ expect(updateSql).not.toContain('updatedby');
+ expect(insertSql).toContain('name');
+ expect(updateSql).toContain('name');
+ });
});
+
diff --git a/apps/web/src/frontend/lib/dataMigratePlans.ts b/apps/web/src/frontend/lib/dataMigratePlans.ts
index 98bf593d..bd0da320 100644
--- a/apps/web/src/frontend/lib/dataMigratePlans.ts
+++ b/apps/web/src/frontend/lib/dataMigratePlans.ts
@@ -46,6 +46,23 @@ function alignRowToColumns(
});
}
+function stripIgnoredColumns(
+ columns: string[],
+ row: unknown[],
+ ignoreLower: Set
+): { columns: string[]; row: unknown[] } {
+ if (ignoreLower.size === 0) return { columns, row };
+ const nextCols: string[] = [];
+ const nextRow: unknown[] = [];
+ for (let i = 0; i < columns.length; i++) {
+ const name = columns[i]!;
+ if (ignoreLower.has(name.toLowerCase())) continue;
+ nextCols.push(name);
+ nextRow.push(row[i]);
+ }
+ return { columns: nextCols, row: nextRow };
+}
+
export function buildDataMigratePlans(opts: {
tableName: string;
dialect: string;
@@ -56,6 +73,11 @@ export function buildDataMigratePlans(opts: {
/** When true, include identity/autoincrement values on INSERT (preserve source IDs). */
includeIdentity: boolean;
identityColumns: Set;
+ /**
+ * Skip these columns on INSERT/UPDATE (trigger-managed createdAt / updatedBy).
+ * Destination triggers can populate them.
+ */
+ ignoreColumns?: string[];
}): { plans: DataMigratePlanItem[]; errors: string[] } {
const {
tableName,
@@ -66,8 +88,10 @@ export function buildDataMigratePlans(opts: {
ops,
includeIdentity,
identityColumns,
+ ignoreColumns = [],
} = opts;
+ const ignoreLower = new Set(ignoreColumns.map((c) => c.toLowerCase()));
const sourceKeys = keyColumnsForGrid(keyNames, sourceColumns);
const destKeys = keyColumnsForGrid(keyNames, destColumns);
const plans: DataMigratePlanItem[] = [];
@@ -79,10 +103,11 @@ export function buildDataMigratePlans(opts: {
errors.push(`insert ${op.keyLabel}: missing source row`);
continue;
}
+ const stripped = stripIgnoredColumns(sourceColumns, op.sourceRow, ignoreLower);
const built = buildPeekInsert({
tableName,
dialect,
- values: rowToValues(sourceColumns, op.sourceRow),
+ values: rowToValues(stripped.columns, stripped.row),
// Empty skip-set when includeIdentity — keep source ID values.
identityColumns: includeIdentity ? undefined : identityColumns,
});
@@ -100,15 +125,17 @@ export function buildDataMigratePlans(opts: {
continue;
}
// UPDATE runs on dest: WHERE uses dest keys; SET uses source values.
+ // Drop trigger/audit columns so we don't overwrite dest trigger output.
const originalAligned = alignRowToColumns(destColumns, op.destRow, sourceColumns);
- const draftAligned = op.sourceRow;
- const keysOnSource: PeekKeyColumn[] = sourceKeys;
+ const srcStripped = stripIgnoredColumns(sourceColumns, op.sourceRow, ignoreLower);
+ const origStripped = stripIgnoredColumns(sourceColumns, originalAligned, ignoreLower);
+ const keysOnSource: PeekKeyColumn[] = keyColumnsForGrid(keyNames, srcStripped.columns);
const built = buildPeekUpdate({
tableName,
dialect,
- columns: sourceColumns,
- originalRow: originalAligned,
- draftRow: draftAligned,
+ columns: srcStripped.columns,
+ originalRow: origStripped.row,
+ draftRow: srcStripped.row,
keyColumns: keysOnSource,
});
if ('error' in built) {
diff --git a/apps/web/src/frontend/lib/resultDataDiff.test.ts b/apps/web/src/frontend/lib/resultDataDiff.test.ts
index c02701d8..c5c4677a 100644
--- a/apps/web/src/frontend/lib/resultDataDiff.test.ts
+++ b/apps/web/src/frontend/lib/resultDataDiff.test.ts
@@ -96,4 +96,23 @@ describe('compareResultGrids', () => {
expect(diff.totalDiffCells).toBe(0);
expect(diff.baseline.cells.size).toBe(0);
});
+
+ it('ignores trigger/audit columns when requested', () => {
+ const baseline = {
+ columns: ['id', 'name', 'createdAt', 'updatedBy'],
+ rows: [[1, 'Alice', '2020-01-01', 'alice']],
+ };
+ const other = {
+ columns: ['id', 'name', 'createdAt', 'updatedBy'],
+ rows: [[1, 'Alice', '2024-06-01', 'bob']],
+ };
+ const withoutIgnore = compareResultGrids(baseline, other);
+ expect(withoutIgnore.baseline.modified).toBe(2);
+
+ const withIgnore = compareResultGrids(baseline, other, {
+ ignoreColumns: ['createdAt', 'updatedBy'],
+ });
+ expect(withIgnore.totalDiffCells).toBe(0);
+ });
});
+
diff --git a/apps/web/src/frontend/lib/resultDataDiff.ts b/apps/web/src/frontend/lib/resultDataDiff.ts
index 25eaf306..33bd4ef8 100644
--- a/apps/web/src/frontend/lib/resultDataDiff.ts
+++ b/apps/web/src/frontend/lib/resultDataDiff.ts
@@ -81,13 +81,17 @@ function mark(
* Diff `other` against `baseline`. Rows align by index within the current page;
* columns align by case-insensitive name. Prefer identical ORDER BY on both
* servers so row indexes mean the same entity.
+ *
+ * `ignoreColumns` skips value compares (e.g. trigger-managed createdAt / updatedBy).
*/
export function compareResultGrids(
baseline: ResultGridLike,
- other: ResultGridLike
+ other: ResultGridLike,
+ opts?: { ignoreColumns?: string[] }
): ResultPairDiff {
const baseSum = emptySummary();
const otherSum = emptySummary();
+ const ignore = new Set((opts?.ignoreColumns ?? []).map((c) => c.toLowerCase()));
const baseByName = new Map();
for (let i = 0; i < baseline.columns.length; i++) {
@@ -103,11 +107,13 @@ export function compareResultGrids(
}
for (const [k, idx] of baseByName) {
+ if (ignore.has(k)) continue;
if (!otherByName.has(k)) {
baseSum.missingColumns.push(baseline.columns[idx]!);
}
}
for (const [k, idx] of otherByName) {
+ if (ignore.has(k)) continue;
if (!baseByName.has(k)) {
otherSum.extraColumns.push(other.columns[idx]!);
}
@@ -118,6 +124,7 @@ export function compareResultGrids(
const shared: { name: string; baseIdx: number; otherIdx: number }[] = [];
for (const [k, baseIdx] of baseByName) {
+ if (ignore.has(k)) continue;
const otherIdx = otherByName.get(k);
if (otherIdx === undefined) continue;
shared.push({ name: baseline.columns[baseIdx]!, baseIdx, otherIdx });
@@ -140,7 +147,7 @@ export function compareResultGrids(
}
// Extra-only columns on this row
for (const [k, otherIdx] of otherByName) {
- if (baseByName.has(k)) continue;
+ if (ignore.has(k) || baseByName.has(k)) continue;
mark(otherSum, r, otherIdx, 'extra');
}
continue;
@@ -157,13 +164,13 @@ export function compareResultGrids(
// Columns only on other → extra cells
for (const [k, otherIdx] of otherByName) {
- if (baseByName.has(k)) continue;
+ if (ignore.has(k) || baseByName.has(k)) continue;
mark(otherSum, r, otherIdx, 'extra');
}
// Columns only on baseline → missing on other side is shown via missingColumns;
// tint baseline cells so the gap is visible while scanning.
for (const [k, baseIdx] of baseByName) {
- if (otherByName.has(k)) continue;
+ if (ignore.has(k) || otherByName.has(k)) continue;
mark(baseSum, r, baseIdx, 'missing');
}
}
diff --git a/apps/web/src/frontend/lib/resultRowDiff.test.ts b/apps/web/src/frontend/lib/resultRowDiff.test.ts
index c5bd7624..0e6c4254 100644
--- a/apps/web/src/frontend/lib/resultRowDiff.test.ts
+++ b/apps/web/src/frontend/lib/resultRowDiff.test.ts
@@ -65,8 +65,32 @@ describe('classifyRowsByKey', () => {
expect(c.inserts).toHaveLength(1);
expect(c.deletes).toHaveLength(0);
});
+
+ it('does not treat differing trigger columns as updates when ignored', () => {
+ const columns = ['id', 'name', 'createdAt', 'updatedBy'];
+ const source = {
+ columns,
+ rows: [[1, 'Alice', '2020-01-01', 'src-user']],
+ };
+ const dest = {
+ columns,
+ rows: [[1, 'Alice', '2024-06-01', 'dest-user']],
+ };
+ const without = classifyRowsByKey({ source, dest, keyNames: ['id'] });
+ expect(without.updates).toHaveLength(1);
+
+ const withIgnore = classifyRowsByKey({
+ source,
+ dest,
+ keyNames: ['id'],
+ ignoreColumns: ['createdAt', 'updatedBy'],
+ });
+ expect(withIgnore.updates).toHaveLength(0);
+ expect(withIgnore.totalOps).toBe(0);
+ });
});
+
describe('selectMigrateOps', () => {
it('respects checkboxes and caps at 500', () => {
const inserts = Array.from({ length: 300 }, (_, i) => ({
diff --git a/apps/web/src/frontend/lib/resultRowDiff.ts b/apps/web/src/frontend/lib/resultRowDiff.ts
index 9ca4828f..4dd8b83b 100644
--- a/apps/web/src/frontend/lib/resultRowDiff.ts
+++ b/apps/web/src/frontend/lib/resultRowDiff.ts
@@ -80,13 +80,15 @@ function nonKeyColumnsDiffer(
destRow: unknown[],
sourceCols: string[],
destCols: string[],
- keyNamesLower: Set
+ keyNamesLower: Set,
+ ignoreLower: 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());
+ const lower = name.toLowerCase();
+ if (keyNamesLower.has(lower) || ignoreLower.has(lower)) continue;
+ const di = destIdx.get(lower);
if (di === undefined) continue;
if (!resultValuesEqual(sourceRow[i], destRow[di])) return true;
}
@@ -98,16 +100,20 @@ function nonKeyColumnsDiffer(
* - insert: key in source only
* - update: key in both, non-key values differ
* - delete: key in dest only
+ *
+ * `ignoreColumns` are excluded from update detection (trigger/audit fields).
*/
export function classifyRowsByKey(opts: {
source: ResultGridLike;
dest: ResultGridLike;
keyNames: string[];
+ ignoreColumns?: string[];
}): RowDiffClassification {
- const { source, dest, keyNames } = opts;
+ const { source, dest, keyNames, ignoreColumns = [] } = opts;
const sourceKeys = keyColumnsForGrid(keyNames, source.columns);
const destKeys = keyColumnsForGrid(keyNames, dest.columns);
const keyNamesLower = new Set(keyNames.map((k) => k.toLowerCase()));
+ const ignoreLower = new Set(ignoreColumns.map((k) => k.toLowerCase()));
if (
sourceKeys.length === 0 ||
@@ -160,7 +166,8 @@ export function classifyRowsByKey(opts: {
dst.row,
source.columns,
dest.columns,
- keyNamesLower
+ keyNamesLower,
+ ignoreLower
)
) {
updates.push({
diff --git a/apps/web/src/frontend/lib/triggerManagedColumns.test.ts b/apps/web/src/frontend/lib/triggerManagedColumns.test.ts
new file mode 100644
index 00000000..9ef5a131
--- /dev/null
+++ b/apps/web/src/frontend/lib/triggerManagedColumns.test.ts
@@ -0,0 +1,43 @@
+/**
+ * Fox Schema (foxschema)
+ * Copyright 2024-2026 Huy Phan
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import { describe, expect, it } from 'vitest';
+import {
+ detectTriggerManagedColumns,
+ isLikelyTriggerManagedColumn,
+} from './triggerManagedColumns';
+
+describe('isLikelyTriggerManagedColumn', () => {
+ it.each([
+ 'createdAt',
+ 'created_at',
+ 'CreatedBy',
+ 'updatedAt',
+ 'updated_by',
+ 'UPDATEDBY',
+ 'modifiedOn',
+ 'last_modified',
+ 'lastModifiedBy',
+ 'rowversion',
+ 'xmin',
+ ])('detects %s', (name) => {
+ expect(isLikelyTriggerManagedColumn(name)).toBe(true);
+ });
+
+ it.each(['id', 'name', 'city', 'create_order', 'update_count', 'status'])(
+ 'does not flag %s',
+ (name) => {
+ expect(isLikelyTriggerManagedColumn(name)).toBe(false);
+ }
+ );
+});
+
+describe('detectTriggerManagedColumns', () => {
+ it('returns matching names from a column list', () => {
+ expect(
+ detectTriggerManagedColumns(['id', 'name', 'createdAt', 'updatedBy', 'city'])
+ ).toEqual(['createdAt', 'updatedBy']);
+ });
+});
diff --git a/apps/web/src/frontend/lib/triggerManagedColumns.ts b/apps/web/src/frontend/lib/triggerManagedColumns.ts
new file mode 100644
index 00000000..c47291d7
--- /dev/null
+++ b/apps/web/src/frontend/lib/triggerManagedColumns.ts
@@ -0,0 +1,65 @@
+/**
+ * Fox Schema (foxschema)
+ * Copyright 2024-2026 Huy Phan
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Columns commonly maintained by INSERT/UPDATE triggers (timestamps, actors).
+ * Compare/migrate should ignore them by default — values differ across servers
+ * even when the business row is the same.
+ */
+
+/** Exact lower-case names we always treat as trigger/audit managed. */
+const EXACT = new Set([
+ 'createdat',
+ 'created_at',
+ 'createdon',
+ 'created_on',
+ 'createdby',
+ 'created_by',
+ 'updatedat',
+ 'updated_at',
+ 'updatedon',
+ 'updated_on',
+ 'updatedby',
+ 'updated_by',
+ 'modifiedat',
+ 'modified_at',
+ 'modifiedon',
+ 'modified_on',
+ 'modifiedby',
+ 'modified_by',
+ 'lastmodified',
+ 'last_modified',
+ 'lastmodifiedat',
+ 'last_modified_at',
+ 'lastmodifiedby',
+ 'last_modified_by',
+ 'rowversion',
+ 'row_version',
+ 'xmin', // Postgres system
+]);
+
+/** Suffix / contains patterns (lower-case, no separators normalized). */
+const PATTERN =
+ /^(created|updated|modified|lastmodified)(at|on|by|date|time|timestamp)?$|^(created|updated|modified)_?(at|on|by|date|time|timestamp)$|_?(created|updated|modified)_?(at|on|by)$/;
+
+function normalize(name: string): string {
+ return name.trim().toLowerCase().replace(/[^a-z0-9_]/g, '');
+}
+
+/** True when the column name looks like a trigger/audit field. */
+export function isLikelyTriggerManagedColumn(name: string): boolean {
+ const n = normalize(name);
+ if (!n) return false;
+ if (EXACT.has(n)) return true;
+ return PATTERN.test(n);
+}
+
+/** Column names from `columns` that match the trigger/audit heuristic. */
+export function detectTriggerManagedColumns(columns: string[]): string[] {
+ return columns.filter((c) => isLikelyTriggerManagedColumn(c));
+}
+
+export function toIgnoreSet(names: string[]): Set {
+ return new Set(names.map((n) => n.toLowerCase()));
+}
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index 3af06700..3224dc21 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -132,14 +132,18 @@ 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 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.
+ (extra). Pick **source** (and **dest** when more than two). **Skip trigger cols**
+ (on by default) ignores audit fields such as `createdAt` / `updatedBy` so
+ trigger differences across databases do not light up as diffs. 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**, **Transaction** (all-or-nothing when Stop is on), and
- **Stop on error** / **Continue on error**. Progress lists each row; failures show
- the key and error. Fox snapshots affected destination rows and records the run
+ **Stop on error** / **Continue on error**. With **Skip trigger cols**, migrate
+ does not treat audit columns as updates and omits them from INSERT/UPDATE so
+ destination triggers can fill them. Progress lists each row; failures show the
+ key and error. 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.
From 699e934095a42861520584f0330cd3a23016af04 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 7 Aug 2026 00:28:15 +0000
Subject: [PATCH 4/4] fix(sql-editor): Compare off by default; user opts into
Add/Edit/Delete
Side-by-side stays plain until Compare data is turned on. Migrate ops
(Add/Edit/Delete) start unchecked so the user chooses what to apply;
Transaction and Stop/Continue remain safety assists.
Co-authored-by: huy.phan9
---
apps/e2e/src/tests/sql-editor-sqlite.test.ts | 23 +++++++++--
.../components/sql-editor/DataMigrateBar.tsx | 41 +++++++++++++------
.../components/sql-editor/ResultsPanel.tsx | 16 +++++---
docs/USER_GUIDE.md | 34 +++++++--------
4 files changed, 77 insertions(+), 37 deletions(-)
diff --git a/apps/e2e/src/tests/sql-editor-sqlite.test.ts b/apps/e2e/src/tests/sql-editor-sqlite.test.ts
index 12a3b7b9..b625541f 100644
--- a/apps/e2e/src/tests/sql-editor-sqlite.test.ts
+++ b/apps/e2e/src/tests/sql-editor-sqlite.test.ts
@@ -116,11 +116,17 @@ describe.skipIf(!ready)('SQL Editor · SQLite multi-credential', () => {
await driver.waitForSelector('[data-testid="sql-result-compare-toggle-0"]', {
timeout: 10_000,
});
+ // Compare data is off by default — plain side-by-side until opted in.
+ expect(await sql.compareToggle(0).isChecked()).toBe(false);
+ expect(await sql.diffCellCount()).toBe(0);
+ expect(await driver.locator('[data-testid="sql-data-migrate-bar-0"]').count()).toBe(0);
+
+ await sql.compareToggle(0).click();
expect(await sql.compareToggle(0).isChecked()).toBe(true);
expect(await sql.compareLegend(0).isVisible()).toBe(true);
expect(await sql.compareBaselineSelect(0).isVisible()).toBe(true);
- // Wait for highlight pass after Compare defaults on.
+ // Wait for highlight pass after Compare is turned on.
await driver.waitForFunction(
() =>
document.querySelectorAll('[data-testid="sql-results-side-by-side"] td[data-diff]').length >
@@ -137,13 +143,23 @@ describe.skipIf(!ready)('SQL Editor · SQLite multi-credential', () => {
expect(results).toMatch(/baseline|source/i);
expect(results).toMatch(/differ|match/i);
- // Data migrate bar (≤500) with insert/update/delete checkboxes.
+ // Data migrate bar: Add / Edit / Delete (user opts in; none checked by default).
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-insert-0"]').isChecked()).toBe(
+ false
+ );
+ expect(await driver.locator('[data-testid="sql-data-migrate-update-0"]').isChecked()).toBe(
+ false
+ );
+ expect(await driver.locator('[data-testid="sql-data-migrate-delete-0"]').isChecked()).toBe(
+ false
+ );
+ expect(await driver.locator('[data-testid="sql-data-migrate-tx-0"]').isVisible()).toBe(true);
expect(await driver.locator('[data-testid="sql-data-migrate-identity-0"]').isVisible()).toBe(
true
);
@@ -152,7 +168,7 @@ describe.skipIf(!ready)('SQL Editor · SQLite multi-credential', () => {
await saveScreenshot(driver, 'sql-editor-data-compare');
await saveSeoScreenshot(driver, 'sql-editor-data-compare');
- // Toggle Compare off → highlights clear.
+ // Toggle Compare off → highlights and migrate bar clear.
await sql.compareToggle(0).click();
await driver.waitForFunction(
() =>
@@ -161,6 +177,7 @@ describe.skipIf(!ready)('SQL Editor · SQLite multi-credential', () => {
{ timeout: 10_000 }
);
expect(await sql.diffCellCount()).toBe(0);
+ expect(await driver.locator('[data-testid="sql-data-migrate-bar-0"]').count()).toBe(0);
});
it('shows the statement strip for multi-statement SQL', async () => {
diff --git a/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx b/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx
index 056a3f12..9a10ca54 100644
--- a/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx
+++ b/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx
@@ -96,13 +96,14 @@ export const DataMigrateBar: React.FC = ({
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);
+ /** User opts into each op — nothing selected until they choose. */
+ const [doInsert, setDoInsert] = useState(false);
+ const [doUpdate, setDoUpdate] = useState(false);
const [doDelete, setDoDelete] = useState(false);
const [includeIdentity, setIncludeIdentity] = useState(false);
- /** One transaction for the whole batch (Stop mode). Off with Continue = per-op commits. */
+ /** Safety: 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). */
+ /** Safety: Continue = skip failures; Stop = abort (rollback if transaction on). */
const [continueOnError, setContinueOnError] = useState(false);
const [applying, setApplying] = useState(false);
const [progress, setProgress] = useState(null);
@@ -371,8 +372,8 @@ export const DataMigrateBar: React.FC = ({
{source.label} → {dest.label}
- {classification.inserts.length} insert · {classification.updates.length} update ·{' '}
- {classification.deletes.length} delete
+ {classification.inserts.length} add · {classification.updates.length} edit ·{' '}
+ {classification.deletes.length} delete available
{selected.uncappedCount > DATA_MIGRATE_ROW_CAP
? ` · capped ${DATA_MIGRATE_ROW_CAP}`
: ''}
@@ -403,6 +404,9 @@ export const DataMigrateBar: React.FC = ({
+
+ Ops
+
= ({
onChange={(e) => setDoInsert(e.target.checked)}
className="rounded border-slate-600"
/>
- Insert ({classification.inserts.length})
+ Add ({classification.inserts.length})
= ({
onChange={(e) => setDoUpdate(e.target.checked)}
className="rounded border-slate-600"
/>
- Update ({classification.updates.length})
+ Edit ({classification.updates.length})
= ({
= ({
/>
Include identity / IDs
+
+
+
+
+ Safety
+
= ({
onClick={() => void apply()}
className="ml-auto px-2 py-0.5 rounded bg-cyan-700/40 border border-cyan-500/40 text-cyan-200 hover:bg-cyan-600/50 disabled:opacity-40 disabled:cursor-not-allowed"
title={
- selected.uncappedCount > DATA_MIGRATE_ROW_CAP
- ? `Over ${DATA_MIGRATE_ROW_CAP} ops — use Server Beam`
- : undefined
+ selected.uncappedCount === 0
+ ? 'Select Add, Edit, and/or Delete first'
+ : selected.uncappedCount > DATA_MIGRATE_ROW_CAP
+ ? `Over ${DATA_MIGRATE_ROW_CAP} ops — use Server Beam`
+ : undefined
}
>
{applying ? (
@@ -495,6 +510,8 @@ export const DataMigrateBar: React.FC = ({
) : selected.uncappedCount > DATA_MIGRATE_ROW_CAP ? (
`Over ${DATA_MIGRATE_ROW_CAP} — Server Beam`
+ ) : selected.uncappedCount === 0 ? (
+ 'Select ops to migrate'
) : (
`Migrate ${selected.uncappedCount} ops`
)}
diff --git a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx
index 56b4f4c3..5a1b9b9e 100644
--- a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx
+++ b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx
@@ -639,7 +639,8 @@ const SideBySideStatementSection: React.FC<{
[items]
);
const canCompare = okGrids.length >= 2;
- const [compareOn, setCompareOn] = useState(true);
+ /** Off by default — side-by-side shows plain grids until the user opts into Compare. */
+ const [compareOn, setCompareOn] = useState(false);
/** Skip createdAt / updatedBy / etc. — values differ across DBs even when rows match. */
const [skipTriggerCols, setSkipTriggerCols] = useState(true);
const [baselineId, setBaselineId] = useState('');
@@ -792,8 +793,13 @@ const SideBySideStatementSection: React.FC<{
className="rounded border-slate-600"
/>
- Compare
+ Compare data
+ {!compareOn && (
+
+ Turn on to highlight diffs and choose Add / Edit / Delete
+
+ )}
{compareOn && (
source
@@ -903,9 +909,9 @@ const SideBySideStatementSection: React.FC<{
/>
{compareActive && (
- Cell colors align by row index; Data migrate matches rows by key columns (source → dest).
- Skip trigger cols ignores createdAt / updatedBy and similar. Use the same ORDER BY when
- scanning. Cap: 500 ops — larger sets use Server Beam.
+ Cell colors align by row index. Choose Add / Edit / Delete yourself; Transaction and Stop /
+ Continue are safety assists. Skip trigger cols ignores createdAt / updatedBy. Same ORDER BY
+ when scanning. Cap: 500 ops — larger sets use Server Beam.
)}
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index 3224dc21..24d007d5 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -129,23 +129,23 @@ compare / migrate). It lives in the same local web UI you open with `foxschema`.
that text is executed (variables still expand).
5. Click **Run**. Results appear below, grouped by connection (stack or side-by-side).
-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 source connection are colored: amber (modified), rose (missing), emerald
- (extra). Pick **source** (and **dest** when more than two). **Skip trigger cols**
- (on by default) ignores audit fields such as `createdAt` / `updatedBy` so
- trigger differences across databases do not light up as diffs. 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**, **Transaction** (all-or-nothing when Stop is on), and
- **Stop on error** / **Continue on error**. With **Skip trigger cols**, migrate
- does not treat audit columns as updates and omits them from INSERT/UPDATE so
- destination triggers can fill them. Progress lists each row; failures show the
- key and error. 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.
+6. **Compare data across servers** — switch the results layout to **Side-by-side**
+ and check two or more Destinations. **Compare data** is off by default so grids
+ stay plain until you turn it on. Then cells that differ from the source are
+ colored: amber (modified), rose (missing), emerald (extra). Pick **source**
+ (and **dest** when more than two). **Skip trigger cols** (on by default) ignores
+ audit fields such as `createdAt` / `updatedBy`. Cell tinting aligns by row
+ index — use the same `ORDER BY` when scanning.
+
+7. **Data migrate (≤500 row ops)** — with Compare on, **Data migrate** appears.
+ You choose **Add / Edit / Delete** (none selected until you check them). Safety
+ assists: **Transaction** (all-or-nothing when Stop is on) and **Stop on error** /
+ **Continue on error**. Optional **Include identity / IDs**. With **Skip trigger
+ cols**, migrate does not treat audit columns as edits and omits them from
+ INSERT/UPDATE so destination triggers can fill them. Progress lists each row;
+ failures show the key and error. 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: