Skip to content

feat(compare)!: rebuild Compare & Sync as a native comparison window (#721) - #1968

Merged
datlechin merged 13 commits into
mainfrom
feat/721-compare-sync
Aug 23, 2026
Merged

feat(compare)!: rebuild Compare & Sync as a native comparison window (#721)#1968
datlechin merged 13 commits into
mainfrom
feat/721-compare-sync

Conversation

@datlechin

@datlechin datlechin commented Jul 27, 2026

Copy link
Copy Markdown
Member

Closes #721. Replaces this PR's previous contents.

Compare & Sync, rebuilt. The engines and their tests survive; the shell, the I/O layer and the eligibility gate are new, and thirteen correctness defects behind them are fixed.

Why it was rebuilt rather than patched

The feature was inert. CompareSyncEligibility gated every comparison on PluginCapabilities.schemaCompare / .dataCompare. A whole-repo grep found those two bits declared in PluginCapabilities.swift and set only by the test fake in CompareSyncExecutorTests; PluginDatabaseDriver.capabilities defaults to []. Every Compare press on every real connection returned "does not report structure metadata that can be compared." Nothing here had ever executed against a database, which is why the defects below went unnoticed.

The window was the wrong idiom. A comparison is a tool you re-run, not a procedure you walk once, so it was modelled as a four-step wizard. That single choice produced every UI symptom: a wizard has no toolbar, so a numbered step header and a bottom action bar had to be invented, both of which the macOS HIG names directly ("Avoid creating custom window UI"; "Avoid putting critical information or actions in a bottom bar, because people often relocate a window in a way that hides its bottom edge"). Its steps were sequential, so the script got a bare TextEditor instead of the app's syntax-highlighted DDLTextView. Its state was transient, so nothing persisted.

Apple's own shape for this was measured from the shipped nibs: FileMerge is a chooser plus a persistent window with a customizable, autosaving NSToolbar. An assistant is for rare irreversible setup.

Eligibility

Sixteen SQL drivers now declare the two capability bits. Nine deliberately do not, each for a checked reason: Redis, MongoDB, DynamoDB, etcd, Elasticsearch, Beancount and SurrealDB cannot express SELECT … ORDER BY … at all; Cassandra's CQL rejects ORDER BY on anything but a clustering column. ClickHouse, BigQuery, Trino and Teradata get .dataCompare only, because at least one of fetchIndexes / fetchForeignKeys is a hardcoded [] and structure compare would under-report. PGlite inherits both from PostgreSQL rather than duplicating the list.

CompareCapabilityDeclarationTests scans the driver sources so this cannot silently regress.

Correctness

Every row was verified, several by measurement.

Defect Fix
Numeric keys compared through Double, so two BIGINTs above 2^53 paired as one row and the engine emitted an UPDATE that overwrote a different row exact Decimal; byte fallback on an unparsable key rather than a shared ?? 0 sentinel
A case-insensitive text key desynchronised the merge join: the server's constraint treats Alice and ALICE as one row, a byte comparator does not PluginColumnInfo.collation threaded into KeyOrdering, case-folded when the collation is CI
The stream-order check ran for text keys only runs for every order kind; it is the only thing standing between a disagreeing server order and a wrong diff
DateFormatter clamps a fractional second to milliseconds, so timestampFractionalDigits above 3 was inert and every microsecond difference read as identical the fraction is split off and parsed as an integer; comparison stays in Int64 nanoseconds, because scaling a Double by 1e9 reintroduced the same precision loss
The 5,000-entry cap truncated the script, not just the preview: a 12,000-row difference produced 5,000 statements and reported success the walk gained an entry sink; Apply runs a fresh streamed pass and the pane says the list is a preview
Data DML was emitted per table in alphabetical order with no foreign key ordering, so a child insert preceded its parent inserts parent-first, updates, then deletes child-first, through ForeignKeyTopologicalSort
Binary values rendered as X'…', which PostgreSQL bytea, MSSQL varbinary and Oracle RAW all reject per-engine literal, curated per type
Generated columns were written, which most engines reject excluded from writeColumns, normalised in the model so no caller can get it wrong
fetchTables reports views, and nothing filtered on kind, so a view reached SchemaSyncScriptBuilder and yielded CREATE TABLE subtractive kind filter, so PostgreSQL's PARTITIONED TABLE still compares
The executor keyed its transaction on supportsTransactions, so a MySQL structure sync reported a rollback that did not happen structure mode asks supportsTransactionalDDL
Snapshots never passed table metadata, so engine and collation were always nil, the notes were dead code and CREATE TABLE dropped ENGINE/CHARSET/COLLATE metadata passed through
One unreadable table threw and the user saw no results; TableDiffResult.comparisonError was read by the UI and written by nothing per-object catch into a Could Not Compare section
Apply executed the generated statements while silently discarding every edit made to the script the script is read-only, with Open in Editor as the only hand-edit path

On that last one: SyncStatement carries hazards computed by SyncSafetyClassifier from the typed operation plan. Re-parsing edited text would discard that classification, so read-only is the correct answer rather than the convenient one.

Architecture

An endpoint is a DatabaseScope, not a connection id. Two databases on one server are now a valid pair, and so are two schemas in one database; the read side and the write side finally agree on qualification. Saved comparisons migrate onto scopes rather than being discarded.

All I/O moved into CompareMetadataService and CompareRowService, in QueryExecutor's shape, routed through DatabaseManager.withMetadataDriver(scope:). The old code reached DatabaseManager.driver(for:) directly, which hands back the connection's single live interactive driver: no metadataRoute, so an embedded engine could be handed a second empty instance, and no SessionDriverGate, so a read could interleave with a tab's query or land on whichever database that tab last switched to. Connections open on demand through ensureConnected, so a comparison no longer requires both windows already open.

SessionDriverGate is not reentrant and a session driver holds one database position, so a data comparison of two scopes on one connection is refused up front by name on engines that cannot pool, rather than deadlocking.

Snapshot reads run in a bounded TaskGroup, bounded to 1 for supportsConnectionPooling == false.

Object scope beyond tables. Views, materialized views, procedures, functions and triggers now compare, through the driver methods the sidebar has used since #2383. They have no parsed form, so SourceObjectDiffEngine compares normalised definition text and SourceObjectSyncBuilder emits drop-and-create; routines are matched on name and argument list, so two PostgreSQL overloads are not confused. StructureDiffEngine stays table-shaped and keeps its tests; CompareObjectResult unifies both so the results list has one row type.

The window

CompareSyncWindowController is an NSWindowController that owns its own NSWindowDelegate (the close guard was a delegate-proxy installed from a background NSViewRepresentable), applyAutosaveName so the frame is restored and not just recorded, and an NSToolbar with a stable identifier, allowsUserCustomization and autosavesConfiguration, attached only once session state exists so a nil-returning delegate cannot poison the saved configuration.

Toolbar: Source, Swap, Target, mode, Compare, Group By, Options, Search, Generate Script, Apply. Source and Target are database pickers that walk connection → database → schema, loading each level when its submenu opens. Every item is mirrored under Database > Compare, per the HIG's rule that a toolbar item must also be a menu command; they route by nil target, so they reach the controller only while its window is key.

Body is AutosavingSplitView. Results are a SwiftUI Table with DisclosureTableRow (both macOS 14.0, confirmed against the SDK interface), so Group By produces real sections. The picker it replaces only re-sorted: both of its cases returned a flat array. Include is a checkbox column with a mixed-state header per group, never a Picker embedded in every row. Detail is Definitions | Rows | Script.

Apply opens a resizable sheet with Script / Summary / Warnings; Cancel is the default button, Apply carries role: .destructive, and Apply stays disabled while any included statement has an unacknowledged hazard. Escape dismisses through cancelOperation, since Cancel already holds Return.

Zero Color.orange / .green / .red literals remain under Views/Compare: status tints resolve through ThemeEngine, reusing the data grid's semantic inserted/modified/deleted colours. No hardcoded pixel frame widths. accessibilityDifferentiateWithoutColor is honoured, and accessibility identifiers sit on leaf controls only.

A shipped crash, found on the way

SQLExportPlugin in 0.67.1 traps on Dictionary(uniqueKeysWithValues:) when two tables in different schemas share a name: "Fatal error: Duplicate values for key". Measured under swiftc -O, exit 133. Fixed here, along with two silent siblings the same audit turned up: the export wrote one schema's rows into the other schema's table, and fetched columns and foreign keys for the first export group only, so every later schema silently lost both.

PluginExportTable gains a schema field. The existing initialiser keeps its exact signature and is now @_disfavoredOverload, per the PluginKit ABI rule.

PluginKit ABI: additive

scripts/check-pluginkit-abi.sh against the merge base reports additions only: the new ForeignKeyTopologicalSort enum, the two capability bits, and PluginExportTable's new field plus new init overload with the old one preserved. No symbol removed or changed, no @frozen layout touched. No currentPluginKitVersion bump and no release-all-plugins.sh run. Please add the abi-additive label.

Registry-only plugins pick up their capability bits on their next release; until then Compare refuses them by name, which is honest.

Verification

  • verify.sh build PASS, lint PASS (0 violations across TablePro, TableProTests, TableProUITests), docs PASS.
  • verify.sh test over the 29 compare suites: 204 executed, 204 passed.
  • verify.sh abi <merge-base>: additive, as above.
  • verify.sh plugins fails only on the pre-existing oracle-nio @TaskLocal macro error in the vendored SPM checkout, which breaks every local AllPlugins build and is unrelated to this change. CI compiles plugins on its own runner.

New tests pin the defects that shipped: decimal key ordering above 2^53, a case-insensitive collation key, microsecond timestamp inequality and the inert precision setting, a diff past the retention cap reaching the script in full, generated columns never appearing in a write, a VIEW excluded while a PARTITIONED TABLE is not, per-engine binary literals, structure sync refusing a transaction where DDL commits implicitly, two same-named tables in different schemas surviving the topological sort, saved comparisons keyed per database and per schema, and grouping producing real sections.

Review pass

/code-review high over the finished diff found nine defects. All nine are fixed here, each with a test:

  • Data-mode foreign key ordering was a no-op. The graph was read from session.sourceSnapshots, which only the structure path fills, so the ordering degenerated to alphabetical and order_items inserted before orders. The data path now records its own snapshots.
  • Identical rows consumed the review cap. Accumulator retained every matched row, so a table with 100,000 matches and ten differences at the end filled the list with matches and dropped every difference, leaving the pane empty under a non-zero count. Only differences are retained now; matches are counted and reported as a number.
  • Structure matching ignored schema. public.users and audit.users collapsed onto one result and a target-side second schema was never reported. The match key now carries the schema when both sides have one.
  • Data plans paired tables by bare name, and read the target with the source's schema. Pairing is schema-aware and a plan records the target's own schema.
  • Rows with a NULL key vanished silently. skippedNullKeyCount had no reader anywhere, so choosing a nullable key dropped rows from the comparison and the sync while the pane said zero differences. The pane now names the count and what to do about it.
  • "All schemas" was removed. It made endpoint.schema nil, which is what let two schemas' tables collapse, and the generated ALTER carries no schema of its own so it would have landed on whichever schema the connection happened to be on. An endpoint on a schema-capable engine names exactly one schema.
  • The SQL dump had become internally inconsistent: the collateral fix qualified DROP and INSERT, but CREATE comes back from the driver verbatim and cannot be. References are now qualified only when the export actually spans more than one container, which restores the previous shape for every single-container export and warns on the one case where the CREATE cannot follow.
  • A composite key's identity was ambiguous. ("a", "b, c") and ("a, b", "c") rendered the same, so excluding one row from a sync excluded the other. Exclusion keys on a separate unambiguous identity; the readable description is unchanged.
  • A failing COMMIT discarded the run record, so after a commit failure the user had an error and no list of what had already executed. The failure travels on the result.

Not done

  • Screenshots are placeholders. The window is behind a Starter licence gate with no test hook, so it cannot be driven in a sandbox without fabricating a licence. docs/images/compare-sync-window*.png are 1560x960 placeholders so the page renders; they need replacing with real captures before release. There is no "before" shot to pair them with, since the wizard is deleted in this PR.
  • Standalone sequences are not compared. The driver protocol exposes fetchDependentSequences(table:schema:) only, which lists sequences owned by one table rather than a schema-wide catalog. A schema-wide fetchSequences would be an additive protocol addition; it is the one remaining gap against Navicat's object list.
  • Cross-engine structure script generation stays refused, compare-only. Column data types are engine-specific strings and generating DDL for one engine from another's metadata is not sound.
  • TableProUITests/CompareSyncUITests covers the menu route, the banner and the disabled states. The flows that need two live fixture connections (an actual run, the close guard firing) are still not automated.

https://claude.ai/code/session_01QZKyY8vPUn3myu82D145pe

@datlechin datlechin added the abi-additive PluginKit ABI diff reviewed as additive; no version bump needed label Jul 27, 2026
@mintlify

mintlify Bot commented Jul 27, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
TablePro 🟢 Ready View Preview Jul 27, 2026, 9:23 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 20bfb6e978

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +63 to +64
let required: PluginCapabilities = mode == .structure ? .schemaCompare : .dataCompare
guard !driver.capabilities.contains(required) else { return nil }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Opt supported drivers into compare capabilities

This eligibility check rejects every real database driver because the commit defines the two capability bits but never adds either bit to any PluginDatabaseDriver.capabilities implementation. I checked all driver capability declarations under Plugins; consequently both structure and data comparisons always stop with the unsupported message before doing any work.

Useful? React with 👍 / 👎.

Comment on lines +110 to +112
let result = try await self.executorRun(
statements: self.statements,
target: target,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply the script text the user edited

When the user changes the editable script preview, the binding only updates editedScript, but Apply still passes the original self.statements here. Thus edits—including removing or correcting a destructive statement—are ignored while the confirmation implies the displayed script will run; either rebuild executable statements from the edited text or make the preview read-only.

Useful? React with 👍 / 👎.

Comment on lines +213 to +217
guard entries.count < limit else {
truncated = true
return
}
entries.append(entry)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not discard rows needed for synchronization

For tables exceeding maxRetainedEntries (5,000 by default), this cap discards every later entry even though generateDataScript() builds SQL exclusively from summary.entries. A table whose first 5,000 rows match but whose 5,001st row differs reports the exact difference count yet generates no statement; other large tables are only partially synchronized. Retention used for display must not truncate the data used to build the sync script.

Useful? React with 👍 / 👎.

Comment on lines +239 to +240
if let leftNumber = Double(leftKey), let rightNumber = Double(rightKey) {
return leftNumber < rightNumber ? .orderedAscending : .orderedDescending

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Compare numeric keys without Double precision loss

When key strings are large exact integers, converting them to Double collapses distinct values—for example, 9007199254740992 and 9007199254740993. The merge join can then choose the wrong stream to advance, misclassify a row present on both sides as an insert, and generate duplicate-key or otherwise incorrect synchronization SQL. Numeric ordering needs an exact decimal/integer comparison consistent with the database ordering.

Useful? React with 👍 / 👎.

Comment on lines +184 to +185
if shouldRollback {
try? await driver.rollbackTransaction()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Propagate rollback failures before reporting success

If rollbackTransaction() fails after cancellation or a statement error, try? silently discards that failure and the returned result still sets rolledBack to true. The apply view then explicitly tells the user that the target is unchanged even though the transaction may remain open or partially applied; propagate the rollback error or record rollback failure separately instead of claiming success.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@mintlify

mintlify Bot commented Jul 27, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
TablePro 🟡 Building Jul 27, 2026, 9:22 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

# Conflicts:
#	CHANGELOG.md
#	TablePro/Core/Services/Infrastructure/WindowOpener.swift
#	TablePro/Views/Infrastructure/WindowOpenerBridge.swift
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@datlechin datlechin changed the title feat(compare): compare and sync structure or data between two connections (#721) feat(compare)!: rebuild Compare & Sync as a native comparison window (#721) Aug 23, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@datlechin
datlechin merged commit 3848a21 into main Aug 23, 2026
9 checks passed
@datlechin
datlechin deleted the feat/721-compare-sync branch August 23, 2026 09:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

abi-additive PluginKit ABI diff reviewed as additive; no version bump needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Data sync between two connections

1 participant