Skip to content

feat(connections): open a SQLite database that lives on an SSH server, read-only - #2553

Merged
datlechin merged 1 commit into
mainfrom
feat/remote-sqlite-over-ssh
Aug 27, 2026
Merged

feat(connections): open a SQLite database that lives on an SSH server, read-only#2553
datlechin merged 1 commit into
mainfrom
feat/remote-sqlite-over-ssh

Conversation

@datlechin

Copy link
Copy Markdown
Member

Fixes #2474.

A SQLite connection can now name a database that lives on an SSH server. It is fetched over SFTP into a copy on this Mac, opened read-only, and the original is never written to.

Why this shape

buildEffectiveConnection is the single place a transport rewrites what a driver sees: it swaps host and port for a tunnel's local port and hands the driver a connection with no idea a tunnel exists. Every transport we ship is an arm of that one switch, but the switch only rewrites a network endpoint, and a file-backed driver opens a path. This adds the arm that rewrites the path. The driver learns nothing, exactly as it learns nothing about a tunnel.

The pane's server half is the existing ConnectionSSHTunnelView, so password, key, agent, keyboard-interactive, jump hosts, TOTP, ~/.ssh/config and saved SSH profiles all work with no new code. What it adds is a path.

Read-only is enforced, not promised

buildRemoteFileEffectiveConnection sets safeModeLevel = .readOnly. The grid, the SQL editor and the AI tools all refuse writes through the gate they already honour, rather than each learning what a remote file is.

That is the honest shape rather than an unfinished one. An edit would land in the copy on this Mac, change nothing on the server, and vanish on the next fetch. SQLite's own documentation says its locking cannot be relied on across a network, so writing to the file in place is not on the table either.

Fetching safely

Where the server carries sqlite3 3.27 or newer, it is asked for a VACUUM INTO snapshot. That is the tier worth having: measured, a snapshot passed integrity_check with 42,239 rows while 22,518 transactions committed against the source, with zero writer errors. A byte copy taken at the same moment was 441 rows behind.

Otherwise the file is copied directly with any -wal or -journal beside it. Those are not optional: measured, opening only the main file of a WAL-mode database returned the checkpointed row and silently omitted the committed one. -shm is deliberately not copied; it is a rebuildable index and a stale one is worse than none.

Every download is verified before anything opens it, because sqlite3_open on a truncated file reports success and shows an empty database. The byte count must match the server's stat, the header must be SQLite's, and integrity_check must pass. Fetches land on a staging name and are promoted only after that.

What the investigation turned up

Two things measured against the vendored Libs/libssh2_arm64.a and a real OpenSSH 10.3 server changed the design. Neither is visible in the header, which carries no doc comments at all:

libssh2_sftp_write short-writes constantly. 8 MiB, SHA-256 both sides: advancing the offset by the requested length wrote 4,224,304 of 8,388,608 bytes and returned no error at any point; advancing by the returned length was byte-exact after 511 short writes.

The obvious rename API cannot replace a file. The header's own libssh2_sftp_rename() macro passes OVERWRITE|ATOMIC|NATIVE, which are SFTP v5 flags; against OpenSSH's v3 the call fails with FX_FAILURE the moment the destination exists. Only posix_rename_ex replaces a file.

Neither is exercised by this PR, which never writes to a server. scripts/check-sftp-contract.sh measures both against a throwaway container so a libssh2 bump re-checks them rather than trusting this description.

Two claims in the issue turned out to be wrong, both verified first-hand. pathFieldRole == .filePath does not identify the file-backed types: it is SQLite and Beancount only, while DuckDB and libSQL declare .database/.apiOnly and hide their path in an additional field. And PluginMetadataRegistry.buildMetadataSnapshot records that reading a new DriverPlugin static off an already-built plugin crashes with EXC_BAD_INSTRUCTION, so the capability is curated app-side instead. The result is that this PR changes no PluginKit API at all.

Scope

SQLite only, and read-only.

DuckDB and libSQL open local files too and are deliberately excluded: DuckDB names its log <database>.wal rather than -wal, and both have local-versus-remote modes where the driver ignores a substituted path entirely, so routing a fetch to them would download a database nothing opens. Beancount is excluded because a ledger is a graph of files reached through include.

Write-back is out. Two adversarial review passes over an earlier, wider version of this change found the remaining defects concentrated there and in multi-engine handling: two connections sharing a working copy while one holds an unlinked inode, per-engine sidecar fingerprinting, ~/.ssh/config aliases making the storage key name the wrong server after a repoint. Those are worth doing properly rather than partly.

Tests

  • LibSSH2SFTPSessionIntegrationTests, against a real OpenSSH server started by scripts/sftp-test-server.sh. Byte-exact 4 MiB download, awkward paths, realpath home and symlink resolution, cancellation, directory refusal, missing-path naming, free space, directory listing. The suite is gated on a reachability probe and skips when nothing answers, because xcodebuild does not pass the invoking shell's environment to the test host.
    Files are seeded on the server and verified against the server's own sha256sum, so a passing download agrees with something this code did not produce.
  • RemoteDatabaseFileTests for identity keying, fingerprint comparison including the write-ahead log, engine-specific sidecar and integrity dispatch, and the download verdicts (truncated, empty, non-database, WAL-mode, complete).
  • RemoteFilePaneValidationTests for the pane that shares SSHTunnelFormState with the SSH Tunnel pane. isFormValid reads every pane's issues whether or not it is visible, so an earlier version of this view model told every SSH-tunnelled MySQL and PostgreSQL connection it needed a remote database file path and disabled Save and Test on all of them. These pin that shut.

No UI automation: the flow needs a live SSH server, so it is not deterministic under TableProUITests.

Verification

  • verify.sh build PASS
  • verify.sh test PASS across the eight suites above
  • swiftlint --strict clean over every changed file
  • docs/scripts/check-writing-style.sh and check-docs-against-source.py both pass

Not included

No remote file browser. The issue scoped it "if that is cheap to add", and a usable one needs paging, permission handling and a path bar.

No screenshots yet: the Remote File pane needs a reachable SSH server to photograph. Flagging rather than omitting, docs/connections/remote-database-files.mdx carries no <Frame> and should gain the light and dark pair before release.

@mintlify

mintlify Bot commented Aug 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 Aug 27, 2026, 6:57 AM

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

@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 73b5801 into main Aug 27, 2026
8 checks passed
@datlechin
datlechin deleted the feat/remote-sqlite-over-ssh branch August 27, 2026 06:58
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.

Open and edit a SQLite or DuckDB file over SSH

1 participant