Skip to content

feat(transfer): copy table rows between PostgreSQL and PostgreSQL/SQLite connections - #1421

Open
Asymtode712 wants to merge 13 commits into
TabularisDB:mainfrom
Asymtode712:feat/transfer-tables-between-database-connections
Open

Asymtode712 wants to merge 13 commits into
TabularisDB:mainfrom
Asymtode712:feat/transfer-tables-between-database-connections

Conversation

@Asymtode712

@Asymtode712 Asymtode712 commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Part of #1405

Adds a guided source-to-target table transfer (first milestone of the "Transfer selected tables directly between database connections" feature request).

  • Scope: PostgreSQL source → PostgreSQL or SQLite target; selected table data; pre-existing, compatible destination tables; append-only.
  • Capability matrix: get_table_transfer_capabilities publishes the supported driver pairs. Everything else is rejected at plan time and hidden in the UI. Plugin drivers don't get this feature (a plugin target/source needs its own exact-conversion contract); the menu entry is hidden for them.
  • Plan before write: plan_table_transfer resolves column mapping (by name, or explicit mapping/skip) and the conversion rule for each column. It blocks on: missing source/target table, unmapped or unknown columns, generated or unsupported types, incompatible types, required target columns with no source, same source/target table, read-only target. The plan is rebuilt in run_table_transfer; a client-supplied plan is never trusted.
  • No silent coercion: values are converted per row and rejected when they would lose information (numeric precision/scale and integer range, text length, float32/64 round-trip, timestamp precision, non-finite floats into SQLite, bare numbers into SQLite JSON columns). NULL, Unicode, bytea, numeric (as exact text), timestamps and JSON are preserved.
  • Writes: append only. Conflict handling is explicit: fail (stop at first rejected row) or skip (ON CONFLICT DO NOTHING, unique/PK conflicts only). No TRUNCATE, DROP, upsert or table creation.
  • Bounded memory, committed work: the source is read through a cursor inside a read-only REPEATABLE READ transaction, one batch at a time (default 500, max 5000). Each batch commits in its own target transaction. A failing batch rolls back only itself.
  • Cancellation: cooperative, between batches (also cancels if the browser session goes away). The result reports rows written/skipped/failed per table, and the UI states how many rows already committed and remain in the target.
  • Guards and privacy: target driver readonly capability blocks the plan; the production write confirmation runs before starting; web requests naming a SQLite/local-path connection as source or target need local-admin authorization. Progress events and errors never contain row values (database errors are reduced to SQLSTATE + constraint/column names).
  • Transports: shared logic in application/table_transfers.rs and the pure table_transfer module; thin Tauri commands in transport/desktop/dump_commands.rs; matching web RPC commands and a table_transfer_progress event. cargo check --no-default-features passes.
  • UI: TableTransferModal (target → review plan → run) opened from the table context menu. Keyboard accessible with visible focus, design tokens only, strings added to all 13 locales.

Related: #1292 (schema comparison), #709 / #1361 (single-table SQL export/import). This transfers rows directly and does not overlap with them.

Demo

Screen.Recording.2026-11-06.at.1.30.39.PM.mov

Limitations (by design for this milestone)

  • PostgreSQL source only; targets PostgreSQL and SQLite.
  • Existing destination tables only; mapping is columns of one table at a time in the UI (the backend accepts several tables).
  • PostgreSQL targets: arrays, ranges, enums, geometric/network types, interval, char(n) are reported as unsupported and block the plan.
  • Float4 source values are transferred as their shortest decimal form (what PostgreSQL displays).

Impact analysis

GitNexus was not available in my environment, so I did a manual caller search for the existing symbols I touched:
DatabaseTransferCommand (+4 variants; callers: api.rs, rpc.rs, rpc_tests.rs), DumpCancellationState (+1 field; one struct literal updated), cancel_session_jobs, emit_progress (now pub(crate)), authorize_saved_access, web authorization_for. No behaviour change for dump/import.

Testing

  • cargo test --lib with a local PostgreSQL 16 on 127.0.0.1:54320 (same fixture as tests/postgres_integration; the transfer tests skip when it isn't reachable): real PostgreSQL → SQLite and PostgreSQL → PostgreSQL transfers across batches (exact values, NUL bytes in bytea, Unicode, numeric(30,10), timestamptz, jsonb), duplicate handling, skip mode, lossy rejection with nothing written, cancellation keeping committed batches, missing target table never created.
  • Unit tests for every conversion rule, mapping/blocking rule, SQL generation, cancel registry; RPC routing, authorization (SQLite as source/target), event authorization.
  • pnpm typecheck, pnpm lint, pnpm test (8157 passing; 14 new).
  • Two Rust tests (persists_and_deletes_custom_themes_in_the_shared_config_directory, announces_committed_personal_theme_changes) also fail on a clean main.

Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.

@kilo-code-bot

kilo-code-bot Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Incremental review of 58413bf1..502ad33f. The range contains a merge of upstream/main (feature work that is not part of this PR) and one PR-owned fix, "set default_sql and on_update in the TableColumn fixture".

  • The PR-owned changes in the range are the default_sql: None and on_update: None fixture fields in src-tauri/src/table_transfer/tests.rs and src-tauri/src/application/table_transfers_tests.rs. Both fields exist on TableColumn in models.rs, so these test fixtures match the current struct definition.
  • Files the PR owns that the merge did not touch (table_row_transfers.rs, table_row_transfers_tests.rs, tableRowTransfer.ts, the locale entries for the transfer) are unchanged since the last review, and the earlier no-issues result still applies.
  • The merged upstream code was not reviewed.
Files Reviewed (2 files)
  • src-tauri/src/table_transfer/tests.rs
  • src-tauri/src/application/table_transfers_tests.rs

Notes: static review only; code was not compiled or tests run.

Previous Review Summaries (8 snapshots, latest commit 58413bf)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 58413bf)

Status: No Issues Found | Recommendation: Merge

Incremental review of 21c87cb9..58413bf1 (one commit: "detect self-copy across DNS aliases via server identity").

  • When the saved connections' host names do not prove they reach the same database, a same-named Postgres table is now checked against what the server reports about itself: pg_control_system().system_identifier, the server-side address and port, and the database name.
  • Different cluster ids or different databases allow the transfer. A matching cluster id, or the same address, counts as the same table and gives sourceEqualsTarget.
  • When identity cannot be established (a probe fails, one side cannot read the cluster id, or the addresses differ and no cluster id is available), the plan is blocked with the new targetIdentityUnverified issue instead of risking a table being written into itself. The code is added to the TypeScript issue-code list and to all 13 locale files.
  • Tests cover compare_identities and two live-Postgres self-copy cases (localhost. vs 127.0.0.1, and localhost vs 127.0.0.1).

No defects were found in the changed code. One behavior to be aware of: the check blocks rather than allows when identity is unprovable, so two genuinely different servers with the same schema and table name are blocked if the role cannot read pg_control_system() and the server-reported addresses differ. This follows from the fail-closed design and the new user-facing message explains it.

Files Reviewed (16 files)
  • src-tauri/src/application/table_row_transfers.rs
  • src-tauri/src/application/table_row_transfers_tests.rs
  • packages/web-ui/src/utils/tableRowTransfer.ts
  • packages/web-ui/src/i18n/locales/*.json (13 locale files)

Notes: static review only; code was not compiled or tests run.

Previous review (commit 21c87cb)

Status: No Issues Found | Recommendation: Merge

Incremental review of cd6d2fe2..21c87cb9. The changes are in src-tauri/src/application/table_row_transfers.rs and its tests.

  • The same-table guard no longer relies on source_connection_id == target_connection_id. A new is_same_table / reaches_same_database check treats two saved connections as the same table when they provably reach the same database.
  • Postgres connections match on database, host and port, with loopback spellings treated as one host and the port defaulting to 5432.
  • SQLite connections match on the canonicalized file path.
  • SSH tunnels match only when the route is provably identical (same saved SSH connection id, or the same host, port and user). Otherwise they count as different routes, so an unprovable case does not block the transfer.
  • plan_tables drops its same_connection parameter, and its callers and existing tests are updated to match.
  • New tests cover aliased saved connections, loopback hosts, differing database, schema, host, port or table name, tunnel routes, and SQLite path aliases.

No new issues were found in the changed code.

Files Reviewed (2 files)
  • src-tauri/src/application/table_row_transfers.rs
  • src-tauri/src/application/table_row_transfers_tests.rs

Notes: static review only; code was not compiled or tests run.

Previous review (commit cd6d2fe)

Status: No Issues Found | Recommendation: Merge

Incremental review of 9e4c46ed..cd6d2fe2. The only change since the last review is a test-only commit (cd6d2fe2) in packages/web-ui/tests/components/modals/TableTransferModal.test.tsx. reachReview() now waits for the target-table <select> to be enabled and to contain the customers option before selecting it, instead of waiting only for the select to render. That removes a race: the options arrive after get_schemas/get_tables resolve, and loading resets the selected table. The new within import is used, and no production code changed.

No new issues were found, and the previously reviewed code is unchanged.

Files Reviewed (1 file)
  • packages/web-ui/tests/components/modals/TableTransferModal.test.tsx

Notes: static review only; code was not compiled or tests run.

Previous review (commit 9e4c46e)

Status: No Issues Found | Recommendation: Merge

Incremental review of a384eabc..9e4c46ed. The only changes since the last review are a merge of upstream/main (ffcdb2db) and a Prettier-only reformat of TableTransferModal.tsx (9e4c46ed). The previously reviewed STRICT-only ANY handling is unchanged.

The merge integrates cleanly with the features that landed on main in the meantime (table export/import to SQL, table_sql, conditional formatting, explorer expansion):

  • DumpCancellationState gains a table_transfers map. Every constructor either uses ..Default::default() or is updated, including table_transfers_tests.rs.
  • The new get_table_transfer_capabilities, plan_table_transfer, run_table_transfer and cancel_table_transfer commands are registered on the desktop, web RPC and DatabaseTransferCommand paths. They do not collide with main's export_table_to_sql / import_table_from_sql.
  • The new row-copy code is in table_row_transfers.rs and the table_transfer module, separate from main's table_transfers.rs, which is untouched by the PR. cancel_session_transfers is wired into cancel_session_jobs.
  • On the web side, the tableRowTransfer.* i18n keys sit beside main's tableTransfer.* keys in every locale. The TableTransferModal context-menu entry sits beside main's export/import entries, and table_transfer_progress is added to the event map and authorization table.

No new issues were found.

Files Reviewed (merge integration points and formatting commit)
  • src-tauri/src/application/database_transfers.rs
  • src-tauri/src/application/mod.rs
  • src-tauri/src/application/table_transfers_tests.rs
  • src-tauri/src/lib.rs
  • src-tauri/src/transport/desktop/dump_commands.rs
  • src-tauri/src/transport/desktop/mod.rs
  • src-tauri/src/transport/web/rpc.rs
  • packages/web-ui/src/api/contract.ts
  • packages/web-ui/src/api/events.ts
  • packages/web-ui/src/components/layout/ExplorerSidebar.tsx
  • packages/web-ui/src/components/modals/TableTransferModal.tsx
  • packages/web-ui/src/hooks/useTableTransferCapabilities.ts
  • packages/web-ui/src/utils/tableRowTransfer.ts

Notes: static review only; code was not compiled or tests run.

Previous review (commit a384eab)

Status: No Issues Found | Recommendation: Merge

Incremental review of 3a6d445e..a384eabc. The change makes ANY untyped only on STRICT SQLite tables, as raised in the earlier review comment. is_strict_table reads pragma_table_list.strict and falls back to non-strict on any query error, which is the safe direction. Ordinary ANY columns now fall through to NUMERIC affinity, so long decimals take the checked lossless-or-refuse path and text is refused. target_strict is threaded through PlanInputs, classify_target and classify_sqlite_target, and every call site and test fixture is updated. New unit tests and an integration test (detects_strict_sqlite_tables) cover strict, ordinary, case-insensitive and missing tables. No new issues were found.

Files Reviewed (6 files)
  • src-tauri/src/application/table_transfers.rs
  • src-tauri/src/application/table_transfers/sqlite.rs
  • src-tauri/src/application/table_transfers_tests.rs
  • src-tauri/src/table_transfer/mapping.rs
  • src-tauri/src/table_transfer/tests.rs
  • src-tauri/src/table_transfer/types.rs

Notes: static review only; code was not compiled or tests run.

Previous review (commit 3a6d445)

Status: No Issues Found | Recommendation: Merge

Incremental review of 77c29c83..3a6d445e. The only change is in TableTransferModal.test.tsx. The "returns to the review step when the run request fails" test now uses a deferred promise. It waits for the running step to render, then rejects inside act, which avoids the unhandled-rejection timing problem. act is already imported and the assertions are unchanged. No new issues were found, and the earlier findings stay fixed.

Files Reviewed (1 file)
  • packages/web-ui/tests/components/modals/TableTransferModal.test.tsx

Notes: static review only; code was not compiled or tests run.

Previous review (commit 77c29c8)

Status: No Issues Found | Recommendation: Merge

Incremental review of 4682db53..77c29c83. Both previously reported warnings are fixed:

  • The cancel flag and Registration guard are now registered before resolve() in table_transfers.rs, and run_job checks the flag before starting each table, so cancels or session-close during planning are honored.
  • TableTransferModal.tsx now returns to the review step on run_table_transfer failure, so retry is possible; a test covers it.
Files Reviewed (4 files)
  • src-tauri/src/application/table_transfers.rs
  • src-tauri/src/application/table_transfers_tests.rs
  • packages/web-ui/src/components/modals/TableTransferModal.tsx
  • packages/web-ui/tests/components/modals/TableTransferModal.test.tsx

Notes: static review only; code was not compiled or tests run.

Previous review (commit 4682db5)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
src-tauri/src/application/table_transfers.rs 309 Cancel flag is registered only after resolve() completes (network I/O). Cancel requests and web-session-close cleanup during the planning phase fail or are missed. Register the key before resolve().
packages/web-ui/src/components/modals/TableTransferModal.tsx 235 When run_table_transfer throws, the modal stays on the run step with result === null, showing only an error and Close, with no retry path.
Files Reviewed (37 files)
  • src-tauri/src/application/table_transfers.rs - 1 issue
  • packages/web-ui/src/components/modals/TableTransferModal.tsx - 1 issue
  • src-tauri/src/application/table_transfers/postgres.rs
  • src-tauri/src/application/table_transfers/sqlite.rs
  • src-tauri/src/application/table_transfers_tests.rs
  • src-tauri/src/application/database_transfers.rs
  • src-tauri/src/application/mod.rs
  • src-tauri/src/lib.rs
  • src-tauri/src/table_transfer/* (convert, mapping, mod, model, tests, types)
  • src-tauri/src/transport/desktop/{dump_commands,mod}.rs
  • src-tauri/src/transport/web/{events,events_tests,rpc,rpc_connection_policy,rpc_tests}.rs
  • packages/web-ui/src/api/{contract,events}.ts
  • packages/web-ui/src/components/layout/ExplorerSidebar.tsx
  • packages/web-ui/src/hooks/useTableTransferCapabilities.ts
  • packages/web-ui/src/utils/tableTransfer.ts
  • packages/web-ui/src/i18n/locales/*.json (13 locales, key parity verified)
  • packages/web-ui/tests/*

Notes: static review only; code was not compiled or tests run. Authorization (source and target connection checks, SQLite/local-path guard), serde/TS type alignment, identifier quoting, bind-parameter limits and batch transaction handling were checked and found sound.

Fix these issues in Kilo Cloud


Reviewed by claude-sonnet-5.5 · Input: 258 · Output: 2K · Cached: 246.2K

Review guidance: REVIEW.md from base branch main

Comment thread src-tauri/src/table_transfer/types.rs Resolved

@debba debba left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for making the transfer plan explicit, rebuilding it before writing, and reporting committed batches separately from a failed or cancelled batch. Keeping this first version append-only with a published supported-pair matrix makes the scope clear.

All 15 targeted frontend tests and typecheck pass on this branch. I have not validated the Rust integration tests or the real database/file workflow yet; the host Rust build stops at missing pkg-config/GLib prerequisites.

I haven't found a blocking issue in the diff. I'll run end-to-end tests for further verification, then proceed with the merge if those checks pass.

…s-between-database-connections

# Conflicts:
#	packages/web-ui/src/api/contract.ts
#	packages/web-ui/src/components/layout/ExplorerSidebar.tsx
#	packages/web-ui/src/utils/tableTransfer.ts
#	packages/web-ui/tests/utils/tableTransfer.test.ts
#	src-tauri/src/application/database_transfers.rs
#	src-tauri/src/application/table_transfers.rs
#	src-tauri/src/application/table_transfers_tests.rs
#	src-tauri/src/transport/desktop/dump_commands.rs
#	src-tauri/src/transport/desktop/mod.rs
#	src-tauri/src/transport/web/rpc.rs
#	src-tauri/src/transport/web/rpc_tests.rs

@debba debba left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found a blocking self-copy case in the real web app. Two saved PostgreSQL connections that resolve to the same database can bypass the same-table check, so the transfer writes back into its source.

To reproduce:

  1. Save connections A and B for the same PostgreSQL server and database.
  2. Create a table without a primary key and insert two rows:
    CREATE TABLE public.alias_copy_test(id integer, note text);
    INSERT INTO public.alias_copy_test VALUES (1, 'first'), (2, 'second');
  3. From that table on A, choose Transfer to another connection, select B, and choose the same schema and table.
  4. Review the plan and start the transfer.

Expected: the plan blocks this as source equals target before any write.

Actual: Start is enabled, the app reports Completed / Rows written: 2, and the original table now contains four rows, two copies of each row. I reproduced this twice with my local PostgreSQL fixture. Both run requests returned HTTP 200, with no console or backend exception.

resolve() currently passes source_connection_id == target_connection_id into plan_tables(). Different saved IDs therefore bypass the guard even when the resolved database, schema and table are identical. Please check the resolved endpoint/table identity across connection aliases and add a regression test with two saved IDs pointing to the same database.

The normal 1,201-row transfer between different tables passed, including a full source/target value comparison, and the 14 live transfer tests passed. This self-copy case still needs to be fixed before merging.

@Asymtode712
Asymtode712 requested a review from debba November 9, 2026 07:40
@Asymtode712

Copy link
Copy Markdown
Contributor Author

@debba I have fixed the self-copy case. The same-table check compared saved connection IDs, so two saved connections to the same PostgreSQL database bypassed it. It now compares the resolved endpoints (driver, host/port and database, or SQLite file, plus schema and table), and tunnels only match when provably the same route. Plan and run share this path, so the plan reports sourceEqualsTarget and Start stays disabled. I added regression tests for the two-saved-IDs case, loopback host spellings, tunnel routes and SQLite path aliases. Could you re-run your live PostgreSQL scenario?

@debba debba left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for replacing the saved-ID comparison with endpoint checks and covering loopback spellings, different databases, and tunnel routes. The 19 frontend tests pass, and all 19 Rust transfer tests pass with PostgreSQL 16 available on port 54320.

There is still a self-copy path through equivalent hostnames. I connected to the same test database using localhost. and 127.0.0.1 with the same port and database; both connections reported the same inet_server_addr(), inet_server_port() and current_database(). Running the exact same_host helper from this head returns false for that pair, while localhost versus 127.0.0.1 returns true.

reaches_same_database therefore treats these two saved connections as different databases. With the same schema and table, is_same_table stays false and the sourceEqualsTarget guard is bypassed again. The same issue applies to two DNS aliases for one PostgreSQL endpoint.

Please compare the effective database/table identity in a way that covers equivalent DNS names, or reject cases where that identity cannot be established safely. Add a regression through the plan/run path with two saved connections, rather than only manually constructed parameter comparisons.

For a live regression fixture, create public.alias_copy_test(id integer, note text) without a primary key and insert (1, 'first'), (2, 'second'). Save A with host localhost. and B with host 127.0.0.1, both pointing to the same server port and database. Planning A's table into B's same table should block; a direct run should also reject it, and the row count should remain two. Drop the fixture table after testing.

I'm requesting changes because the guard still permits a transfer back into its source. I verified the hostname comparison and the shared PostgreSQL endpoint directly; I did not repeat this variant in the UI.

@Asymtode712

Copy link
Copy Markdown
Contributor Author

@debba Fixed this... Host-name comparison can't prove two saved connections reach different databases, so when names and strings don't settle it, the plan now asks each PostgreSQL server for its identity (cluster system_identifier, plus server address/port as a fallback). Same cluster → sourceEqualsTarget; different clusters → allowed; identity that can't be established → blocked with a new targetIdentityUnverified issue (translated in all locales). Regressions go through plan() and run() with two saved connections (localhost. and 127.0.0.1) on a table shaped like your alias_copy_test fixture; the run is rejected and the table keeps two rows. All 22 transfer tests pass against PostgreSQL 16 on 54320. Please re-run your live fixture, and tell me if you want the unverified case to warn instead of block.

@Asymtode712
Asymtode712 requested a review from debba November 9, 2026 08:43

@debba debba left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for adding the server identity check and the regression coverage for aliases. The frontend transfer suites pass on this head with 15 tests.

The CI test job is currently blocked by a Rust compilation error. In src/table_transfer/tests.rs, the TableColumn initializer is missing the default_sql and on_update fields added on main. CI reports E0063 before it can run the library tests. Please update the fixture for the current model and rerun the tests.

I have not yet repeated the live PostgreSQL alias transfer test on this head, so the passing frontend tests should not be read as validation of the server identity path.

@Asymtode712
Asymtode712 requested a review from debba November 9, 2026 10:44
@Asymtode712

Copy link
Copy Markdown
Contributor Author

@debba I have made the changes, the CI is green now, kindly review this PR

@NewtTheWolf NewtTheWolf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for filling the new TableColumn fields and updating the transfer tests. I rechecked 3b648c0: typecheck, the 31 affected frontend tests and all 2,516 Rust library tests pass.

I also reran the 22 table-row-transfer tests against a dedicated PostgreSQL 17 fixture with both IPv4 and IPv6 loopback bindings. None were skipped. PostgreSQL-to-PostgreSQL and PostgreSQL-to-SQLite transfers pass, and both localhost/127.0.0.1 alias cases reject copying a table into itself while preserving its two source rows.

The compilation and live alias-test concerns from the earlier review are resolved. Approved.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants