From 2ff876f56a906f9360c74104899400bdfa8c7a06 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 17:58:34 +0000 Subject: [PATCH 1/9] feat(compare): wire Keys/Sync column UX for side-by-side migrate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DataGrid: optional sticky Sync column (rowSync prop, sky styling) - DataMigrateBar: controlled Keys checkboxes, selectedSyncKeys filter, Sync all button, colorful migrate bar, toast when ops but no sync rows - ResultsPanel: selectedSyncKeys state, rowSync on dest grid, restyled compare toolbar with duplicate-key warning and aligned-row legend - USER_GUIDE §6-7: document Keys, Sync column, Sync all - Lib: key-aligned dual-grid insert/delete tint, filterOpsByKeyLabels Co-authored-by: huy.phan9 --- .../components/sql-editor/DataGrid.tsx | 63 +++++- .../components/sql-editor/DataMigrateBar.tsx | 212 +++++++++++++----- .../components/sql-editor/ResultsPanel.tsx | 110 ++++++--- apps/web/src/frontend/lib/resultKeyAlign.ts | 103 ++++++++- apps/web/src/frontend/lib/resultRowDiff.ts | 24 ++ docs/USER_GUIDE.md | 26 ++- 6 files changed, 440 insertions(+), 98 deletions(-) diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx index 52a69f82..f955903b 100644 --- a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx @@ -22,6 +22,7 @@ const COL_LONG_TEXT_PX = 200; /** Upper bound when double-clicking a header to fit content. */ const COL_FIT_MAX_PX = 720; const ROW_NUM_PX = 48; +const SYNC_COL_PX = 44; /** Fixed row height for windowing (must match rendered row). Off-screen pages live in pageCache LRU, not the DOM. */ const ROW_H_PX = 28; /** Taller rows for Data Peek’s larger/bolder type. */ @@ -274,6 +275,11 @@ export const DataGrid: React.FC<{ * (`modified` / `missing` / `extra`), or null when unchanged. */ cellHighlight?: (rowIdx: number, colIdx: number) => CellDiffKind | null; + /** Compare migrate: per-row Sync checkbox (sticky right); null = matching row. */ + rowSync?: { + isChecked: (rowIdx: number) => boolean | null; + onToggle: (rowIdx: number, checked: boolean) => void; + }; }> = React.memo( ({ result, @@ -297,6 +303,7 @@ export const DataGrid: React.FC<{ toolbarExtra, emphasis = false, cellHighlight, + rowSync, }) => { const upsertVariable = useSqlEditorStore((s) => s.upsertVariable); const rowH = emphasis ? ROW_H_EMPHASIS_PX : ROW_H_PX; @@ -505,9 +512,12 @@ export const DataGrid: React.FC<{ const order = colOrder.length === sourceColumns.length ? colOrder : identityOrder(sourceColumns.length); const orderedColumns = order.map((i) => sourceColumns[i]!); + const syncColPx = rowSync ? SYNC_COL_PX : 0; const tableWidth = - ROW_NUM_PX + order.reduce((sum, i) => sum + (colWidths[i] ?? COL_DEFAULT_PX), 0); - const colCount = 1 + order.length; + ROW_NUM_PX + + order.reduce((sum, i) => sum + (colWidths[i] ?? COL_DEFAULT_PX), 0) + + syncColPx; + const colCount = 1 + order.length + (rowSync ? 1 : 0); const totalRows = sourceRows.length; const start = Math.max(0, Math.floor(scrollTop / rowH) - OVERSCAN); @@ -566,6 +576,9 @@ export const DataGrid: React.FC<{ }} /> ))} + {rowSync ? ( + + ) : null} @@ -674,6 +687,18 @@ export const DataGrid: React.FC<{ ); })} + {rowSync ? ( + + Sync + + ) : null} @@ -763,6 +788,40 @@ export const DataGrid: React.FC<{ ); })} + {rowSync ? ( + + {(() => { + const syncVal = rowSync.isChecked(i); + if (syncVal === null) { + return ( + + — + + ); + } + return ( + { + e.stopPropagation(); + rowSync.onToggle(i, e.target.checked); + }} + onClick={(e) => e.stopPropagation()} + className="rounded border-sky-500/60 accent-sky-500" + title="Include in migrate" + /> + ); + })()} + + ) : null} ); })} diff --git a/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx b/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx index c5730264..f413ad2f 100644 --- a/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx @@ -6,9 +6,9 @@ * 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, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; -import { ArrowRightLeft, History, Loader2, X } from 'lucide-react'; +import { ArrowRightLeft, CheckCheck, History, Loader2, X } from 'lucide-react'; import { apiExecuteDataMigrate, apiFinishDataMigrate, @@ -21,8 +21,10 @@ import { } from '../../api/dataMigrateApi'; import { buildDataMigratePlans, buildDestSnapshotJson } from '../../lib/dataMigratePlans'; import { + allDiffKeyLabels, classifyRowsByKey, DATA_MIGRATE_ROW_CAP, + filterOpsByKeyLabels, migrateGridsAreComplete, selectMigrateOps, type ClassifiedRowDiff, @@ -65,6 +67,9 @@ interface Props { /** Controlled key columns (shared with Compare alignment). */ keyNames?: string[]; onKeyNamesChange?: (names: string[]) => void; + /** Row Sync checkboxes — which differing keys to include in migrate. */ + selectedSyncKeys?: ReadonlySet; + onSelectedSyncKeysChange?: (keys: Set) => void; onAfterMigrate?: () => void; onOpenServerBeamSample?: () => void; } @@ -74,8 +79,10 @@ export const DataMigrateBar: React.FC = ({ source, dest, ignoreColumns = [], - keyNames: keyNamesProp, + keyNames: keyNamesProp = [], onKeyNamesChange, + selectedSyncKeys = new Set(), + onSelectedSyncKeysChange, onAfterMigrate, onOpenServerBeamSample, }) => { @@ -96,9 +103,7 @@ export const DataMigrateBar: React.FC = ({ const table: TableSchema | undefined = editTarget.ok ? editTarget.table : undefined; const tableName = table?.name ?? ''; - // Only PK / non-partial unique keys that appear in the result — never fall back - // to "first column" (non-unique WHERE would UPDATE/DELETE multiple rows). - const keyNames = useMemo( + const preferredKeyNames = useMemo( () => resolvePeekKeyColumns(table, source.columns) .filter((k) => k.resultIndex >= 0) @@ -106,6 +111,26 @@ export const DataMigrateBar: React.FC = ({ [table, source.columns] ); + const sharedColumns = useMemo(() => { + const destLower = new Set(dest.columns.map((c) => c.toLowerCase())); + return source.columns.filter((c) => destLower.has(c.toLowerCase())); + }, [source.columns, dest.columns]); + + const keyNames = keyNamesProp; + + const toggleKeyColumn = (col: string) => { + if (!onKeyNamesChange) return; + const lower = col.toLowerCase(); + const has = keyNames.some((k) => k.toLowerCase() === lower); + if (has) { + const next = keyNames.filter((k) => k.toLowerCase() !== lower); + if (next.length === 0) return; + onKeyNamesChange(next); + } else { + onKeyNamesChange([...keyNames, col]); + } + }; + /** User opts into each op — nothing selected until they choose. */ const [doInsert, setDoInsert] = useState(false); const [doUpdate, setDoUpdate] = useState(false); @@ -138,6 +163,17 @@ export const DataMigrateBar: React.FC = ({ [source.columns, source.rows, dest.columns, dest.rows, keyNames, ignoreColumns] ); + const diffLabelsKey = useMemo( + () => allDiffKeyLabels(classification).join('\0'), + [classification] + ); + + useEffect(() => { + if (!onSelectedSyncKeysChange || !diffLabelsKey) return; + const labels = diffLabelsKey.split('\0').filter(Boolean); + onSelectedSyncKeysChange(new Set(labels)); + }, [diffLabelsKey, onSelectedSyncKeysChange]); + const selected = useMemo( () => selectMigrateOps(classification, { @@ -148,6 +184,16 @@ export const DataMigrateBar: React.FC = ({ [classification, doInsert, doUpdate, doDelete] ); + const filtered = useMemo( + () => filterOpsByKeyLabels(selected.ops, selectedSyncKeys, DATA_MIGRATE_ROW_CAP), + [selected.ops, selectedSyncKeys] + ); + + const syncAll = () => { + if (!onSelectedSyncKeysChange) return; + onSelectedSyncKeysChange(new Set(allDiffKeyLabels(classification))); + }; + const openHistory = async () => { setHistoryOpen(true); try { @@ -210,6 +256,16 @@ export const DataMigrateBar: React.FC = ({ toast({ tone: 'info', title: 'Nothing to migrate', body: 'Grids match for the selected ops.' }); return; } + if (filtered.uncappedCount === 0) { + toast({ + tone: 'warning', + title: 'No rows selected for Sync', + body: + 'You chose Add / Edit / Delete but no differing rows are checked. ' + + 'Use the Sync column on the destination grid or click Sync all.', + }); + return; + } if (classification.duplicateKeys > 0) { toast({ tone: 'warning', @@ -220,12 +276,12 @@ export const DataMigrateBar: React.FC = ({ }); return; } - if (selected.uncappedCount > DATA_MIGRATE_ROW_CAP) { + if (filtered.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. ` + + `This compare has ${filtered.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).', @@ -242,7 +298,7 @@ export const DataMigrateBar: React.FC = ({ sourceColumns: source.columns, destColumns: dest.columns, keyNames, - ops: selected.ops, + ops: filtered.ops, includeIdentity, identityColumns: editability.identityColumns, ignoreColumns, @@ -258,7 +314,7 @@ export const DataMigrateBar: React.FC = ({ const snapshotJson = buildDestSnapshotJson({ destColumns: dest.columns, - ops: selected.ops, + ops: filtered.ops, }); const script = [ `-- useTransaction=${useTransaction} continueOnError=${continueOnError}`, @@ -304,7 +360,6 @@ export const DataMigrateBar: React.FC = ({ let rolledBack = false; 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( { @@ -395,91 +450,133 @@ export const DataMigrateBar: React.FC = ({ if (!canCompareReady(source, dest)) return null; + const migrateCount = filtered.uncappedCount; + const overCap = migrateCount > DATA_MIGRATE_ROW_CAP; + return (
-
- - +
+ + Data migrate - + {source.label} → {dest.label} - + {classification.inserts.length} add · {classification.updates.length} edit ·{' '} {classification.deletes.length} delete available - {selected.uncappedCount > DATA_MIGRATE_ROW_CAP - ? ` · capped ${DATA_MIGRATE_ROW_CAP}` + {selected.uncappedCount > migrateCount + ? ` · ${migrateCount} synced` : ''} + {overCap ? ` · capped ${DATA_MIGRATE_ROW_CAP}` : ''}
-
- Keys - {keyNames.length > 0 ? ( - keyNames.map((c) => ( - - {c} - - )) +
+ Keys + {sharedColumns.length > 0 ? ( + sharedColumns.map((col) => { + const checked = keyNames.some((k) => k.toLowerCase() === col.toLowerCase()); + const preferred = preferredKeyNames.some((k) => k.toLowerCase() === col.toLowerCase()); + return ( + + ); + }) ) : ( - {editability.reason || 'No PK/unique key in this result — migrate disabled.'} + No shared columns between source and destination grids. )} + {keyNames.length === 0 && sharedColumns.length > 0 && ( + Pick at least one key column. + )} + {preferredKeyNames.length === 0 && editability.reason && ( + {editability.reason} + )}
-
- +
+ Ops -
-
+
Safety @@ -533,35 +630,40 @@ export const DataMigrateBar: React.FC = ({ applying || !canDml || selected.uncappedCount === 0 || + migrateCount === 0 || classification.duplicateKeys > 0 || - selected.uncappedCount > DATA_MIGRATE_ROW_CAP + overCap } 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" + className="ml-auto px-3 py-1 rounded-md bg-cyan-600/50 border border-cyan-400/50 text-sm font-bold text-cyan-100 hover:bg-cyan-500/60 disabled:opacity-40 disabled:cursor-not-allowed shadow-sm shadow-cyan-500/20" title={ 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 + : migrateCount === 0 + ? 'Check rows in the Sync column' + : overCap + ? `Over ${DATA_MIGRATE_ROW_CAP} ops — use Server Beam` + : undefined } > {applying ? ( - - Migrating… + + Migrating… - ) : selected.uncappedCount > DATA_MIGRATE_ROW_CAP ? ( + ) : overCap ? ( `Over ${DATA_MIGRATE_ROW_CAP} — Server Beam` ) : selected.uncappedCount === 0 ? ( 'Select ops to migrate' + ) : migrateCount === 0 ? ( + 'Select Sync rows' ) : ( - `Migrate ${selected.uncappedCount} ops` + `Migrate ${migrateCount} ops` )}
{!editTarget.ok && ( -

+

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

)} diff --git a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx index 4b26c5e0..691497f9 100644 --- a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx @@ -126,6 +126,10 @@ const ResultGridPane: React.FC<{ compareBadge?: string | null; /** Key-aligned compare remaps rows — disable inline CRUD to avoid wrong targets. */ compareLocked?: boolean; + rowSync?: { + isChecked: (rowIdx: number) => boolean | null; + onToggle: (rowIdx: number, checked: boolean) => void; + }; }> = ({ item, refreshing, @@ -137,6 +141,7 @@ const ResultGridPane: React.FC<{ diffSummary = null, compareBadge = null, compareLocked = false, + rowSync, }) => { const schemaCache = useSqlEditorStore((s) => s.schemaCache); const openDataPeekFromFk = useSqlEditorStore((s) => s.openDataPeekFromFk); @@ -300,6 +305,7 @@ const ResultGridPane: React.FC<{ onSelectRow={crud.onSelectRow} toolbarExtra={toolbarExtra} cellHighlight={cellHighlight} + rowSync={rowSync} /> {linkColumns && linkColumns.size > 0 && (

boolean | null; + onToggle: (rowIdx: number, checked: boolean) => void; + }; }> = ({ item, refreshing, @@ -336,6 +346,7 @@ const PaneBody: React.FC<{ diffSummary = null, compareBadge = null, compareLocked = false, + rowSync, }) => { if (item.kind === 'grid') { return ( @@ -350,6 +361,7 @@ const PaneBody: React.FC<{ diffSummary={diffSummary} compareBadge={compareBadge} compareLocked={compareLocked} + rowSync={rowSync} /> ); } @@ -412,6 +424,14 @@ const ResizablePaneRow: React.FC<{ badgeByConnection?: Record; /** Key-aligned compare remaps rows — lock inline CRUD. */ compareLocked?: boolean; + /** Per connectionId: row Sync column (destination grid). */ + rowSyncByConnection?: Record< + string, + { + isChecked: (rowIdx: number) => boolean | null; + onToggle: (rowIdx: number, checked: boolean) => void; + } + >; }> = ({ items, rowKey, @@ -422,6 +442,7 @@ const ResizablePaneRow: React.FC<{ diffByConnection, badgeByConnection, compareLocked = false, + rowSyncByConnection, }) => { const rowRef = useRef(null); const [widths, setWidths] = useState(() => items.map(() => PANE_DEFAULT_PX)); @@ -519,6 +540,7 @@ const ResizablePaneRow: React.FC<{ diffSummary={diffByConnection?.[item.connectionId] ?? null} compareBadge={badgeByConnection?.[item.connectionId] ?? null} compareLocked={compareLocked} + rowSync={rowSyncByConnection?.[item.connectionId]} />

(''); /** Shared with Data migrate — Compare aligns rows by these keys. */ const [keyNames, setKeyNames] = useState([]); + /** Row Sync checkboxes — which differing keys to include in migrate. */ + const [selectedSyncKeys, setSelectedSyncKeys] = useState>(() => new Set()); const schemaCache = useSqlEditorStore((s) => s.schemaCache); const connections = useSyncStore((s) => s.connections); @@ -801,6 +825,11 @@ const SideBySideStatementSection: React.FC<{ if (keyAligned.insertCount > 0) legendBits.push(`${keyAligned.insertCount} add`); if (keyAligned.deleteCount > 0) legendBits.push(`${keyAligned.deleteCount} delete`); if (keyAligned.matchCount > 0) legendBits.push(`${keyAligned.matchCount} match`); + if (keyAligned.duplicateKeys > 0) { + legendBits.push( + `⚠ ${keyAligned.duplicateKeys} duplicate key${keyAligned.duplicateKeys === 1 ? '' : 's'} skipped` + ); + } if (triggerIgnoreColumns.length > 0) { legendBits.push(`skipping ${triggerIgnoreColumns.join(', ')}`); } @@ -938,6 +967,31 @@ const SideBySideStatementSection: React.FC<{ ignoreOpts, ]); + const rowSyncByConnection = useMemo(() => { + if (!compareActive || !keyAligned || !destId) return undefined; + return { + [destId]: { + isChecked: (rowIdx: number): boolean | null => { + const op = keyAligned.rowOps[rowIdx]; + if (op === 'match') return null; + const label = keyAligned.rowKeyLabels[rowIdx]; + if (!label) return false; + return selectedSyncKeys.has(label); + }, + onToggle: (rowIdx: number, checked: boolean) => { + const label = keyAligned.rowKeyLabels[rowIdx]; + if (!label) return; + setSelectedSyncKeys((prev) => { + const next = new Set(prev); + if (checked) next.add(label); + else next.delete(label); + return next; + }); + }, + }, + }; + }, [compareActive, keyAligned, destId, selectedSyncKeys]); + const insertServerBeamSample = () => { const sample = buildSampleBookmarks().find((b) => b.id === 'sample-server-beam-chunked'); if (!sample) return; @@ -955,33 +1009,33 @@ const SideBySideStatementSection: React.FC<{
{canCompare && (
-