Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- SSH tunnel, Cloudflare Tunnel, SOCKS proxy and tunnel command for Weaviate, Typesense and Elasticsearch. (#3277)

### Fixed

- Cloudflare Tunnel offered for BigQuery, DuckDB, DynamoDB and other drivers that cannot use it, until their plugin was installed.

## [0.78.0] - 2026-10-07

TablePro in French: pick Français in Settings > General.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ final class ElasticsearchPlugin: NSObject, TableProPlugin, DriverPlugin {
static let supportsDatabaseSwitching = false
static let supportsImport = false
static let supportsExport = true
static let supportsSSH = false
static let supportsSSH = true
static let supportsSSL = true
static let supportsReadOnlyMode = true
static let supportsForeignKeyDisable = false
Expand Down
2 changes: 1 addition & 1 deletion Plugins/TypesenseDriverPlugin/TypesensePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ final class TypesensePlugin: NSObject, TableProPlugin, DriverPlugin {
static let supportsDatabaseSwitching = false
static let supportsImport = false
static let supportsExport = true
static let supportsSSH = false
static let supportsSSH = true
static let supportsSSL = true
static let supportsReadOnlyMode = true
static let supportsForeignKeyDisable = false
Expand Down
2 changes: 1 addition & 1 deletion Plugins/WeaviateDriverPlugin/WeaviatePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ final class WeaviatePlugin: NSObject, TableProPlugin, DriverPlugin {
static let supportsDatabaseSwitching = false
static let supportsImport = false
static let supportsExport = true
static let supportsSSH = false
static let supportsSSH = true
static let supportsSSL = true
static let supportsReadOnlyMode = true
static let supportsForeignKeyDisable = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -625,7 +625,6 @@ extension PluginMetadataRegistry {
supportsGeneratedColumns: true,
supportsUserDefinedTypeBrowse: true,
defaultSSLMode: .disabled,
supportsCloudflareTunnel: false,
supportsConnectionPooling: false
),
schema: PluginMetadataSnapshot.SchemaInfo(
Expand Down Expand Up @@ -691,7 +690,6 @@ extension PluginMetadataRegistry {
supportsCheckConstraints: true,
supportsGeneratedColumns: true,
supportsDatabaseTriggerBrowse: true,
supportsCloudflareTunnel: false,
localFilePathField: .database,
newDatabaseFileExtensions: Self.sqliteFileExtensions,
supportsRemoteDatabaseFile: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ extension PluginMetadataRegistry {
supportsSchemaSwitching: false,
supportsImport: false,
supportsExport: true,
supportsSSH: false,
supportsSSH: true,
supportsSSL: true,
supportsCascadeDrop: false,
supportsForeignKeyDisable: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@ extension PluginMetadataRegistry {
defaultSSLMode: .verifyIdentity,
supportsOpportunisticTLS: false,
tlsImpliedPorts: [443],
verifiesServerWithSystemTrust: true,
supportsCloudflareTunnel: false
verifiesServerWithSystemTrust: true
),
schema: PluginMetadataSnapshot.SchemaInfo(
defaultSchemaName: "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ extension PluginMetadataRegistry {
supportsDropIndex: false,
supportsModifyPrimaryKey: false,
supportsOpportunisticTLS: false,
supportsCloudflareTunnel: false,
pagination: .leadingRowsOnly(maximumRows: 10_000),
isEngineReadOnly: true
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,15 @@ extension PluginMetadataRegistry {
supportsSchemaSwitching: false,
supportsImport: false,
supportsExport: true,
supportsSSH: false,
supportsSSH: true,
supportsSSL: true,
supportsCascadeDrop: false,
supportsForeignKeyDisable: false,
supportsReadOnlyMode: true,
supportsQueryProgress: false,
requiresReconnectForDatabaseSwitch: false,
supportsDropDatabase: false,
supportsOpportunisticTLS: false,
supportsCloudflareTunnel: false
supportsOpportunisticTLS: false
),
schema: PluginMetadataSnapshot.SchemaInfo(
defaultSchemaName: "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ extension PluginMetadataRegistry {
supportsSchemaSwitching: false,
supportsImport: false,
supportsExport: true,
supportsSSH: false,
supportsSSH: true,
supportsSSL: true,
supportsCascadeDrop: false,
supportsForeignKeyDisable: false,
Expand All @@ -32,7 +32,6 @@ extension PluginMetadataRegistry {
supportsDropIndex: false,
supportsModifyPrimaryKey: false,
supportsOpportunisticTLS: false,
supportsCloudflareTunnel: false,
supportsPrincipalConnectionLimit: false
),
schema: PluginMetadataSnapshot.SchemaInfo(
Expand Down
10 changes: 6 additions & 4 deletions TablePro/Core/Plugins/PluginMetadataRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ struct PluginMetadataSnapshot: Sendable {
var tlsImpliedPorts: [Int] = []
var verifiesServerWithSystemTrust: Bool = false
var supportsPerConnectionCertificatePaths: Bool = true
var supportsCloudflareTunnel: Bool = true
var supportsClientKeyPassphrase: Bool = false
var supportsConnectionPooling: Bool = true
/// Whether two pooled drivers for the same connection sit on one physical session. Snowflake
Expand Down Expand Up @@ -122,6 +121,11 @@ struct PluginMetadataSnapshot: Sendable {

var supportsSOCKSProxy: Bool { supportsSSH }

/// `cloudflared access tcp` forwards a loopback port just as an SSH tunnel does. A stored flag
/// drifted: it defaulted to true in the built-in snapshots, so a registry type the plugin
/// declares without SSH offered Cloudflare Tunnel until that plugin was installed.
var supportsCloudflareTunnel: Bool { supportsSSH }

/// A tunnel command forwards a loopback port to the server the connection names, so it
/// applies wherever an SSH tunnel would. Computed for the same reason `supportsSOCKSProxy`
/// is: a stored flag would need an opt-out line in every hand-written snapshot.
Expand Down Expand Up @@ -161,8 +165,7 @@ struct PluginMetadataSnapshot: Sendable {
supportsDropIndex: true,
supportsModifyPrimaryKey: true,
defaultSSLMode: .disabled,
supportsOpportunisticTLS: true,
supportsCloudflareTunnel: true
supportsOpportunisticTLS: true
)
}

Expand Down Expand Up @@ -736,7 +739,6 @@ final class PluginMetadataRegistry: @unchecked Sendable {
.verifiesServerWithSystemTrust ?? false,
supportsPerConnectionCertificatePaths: existingSnapshot?.capabilities
.supportsPerConnectionCertificatePaths ?? true,
supportsCloudflareTunnel: driverType.supportsSSH,
supportsClientKeyPassphrase: existingSnapshot?.capabilities.supportsClientKeyPassphrase ?? false,
supportsConnectionPooling: existingSnapshot?.capabilities.supportsConnectionPooling ?? true,
pooledDriversShareOneSession: existingSnapshot?.capabilities
Expand Down
54 changes: 54 additions & 0 deletions TableProTests/Core/Plugins/PortForwardCapabilityTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// PortForwardCapabilityTests.swift
// TableProTests
//
// Weaviate, Typesense and Elasticsearch shipped without an SSH tunnel although each is a server
// on one HTTP port, the same shape as Trino and SurrealDB, which had one. These read the curated
// built-in table, which is what the form reads before a plugin is installed.
//

import Foundation
@testable import TablePro
import TableProPluginKit
import Testing

@MainActor
struct PortForwardCapabilityTests {
private var manager: PluginManager { PluginManager.shared }

/// PGlite's socket server binds loopback on this Mac. SAP HANA's page documents a manual
/// forward with TLS Server Name, which keeps Verify Identity that a tunnel would drop.
private static let networkTypesWithoutPortForward: Set<String> = ["PGlite", "SAP HANA"]

@Test("every network type offers an SSH tunnel unless it is listed with a reason")
func networkTypesOfferSSH() {
let declined = DatabaseType.allKnownTypes.filter { type in
guard let snapshot = PluginMetadataRegistry.shared.snapshot(for: type),
snapshot.connectionMode == .network
else { return false }
return !snapshot.capabilities.supportsSSH
}
#expect(Set(declined.map(\.rawValue)) == Self.networkTypesWithoutPortForward)
}

@Test(
"a self-hosted HTTP engine offers every transport that forwards a port",
arguments: [DatabaseType.weaviate, .typesense, .elasticsearch]
)
func httpEnginesOfferPortForwards(type: DatabaseType) {
#expect(manager.supportsSSH(for: type))
#expect(manager.supportsCloudflareTunnel(for: type))
#expect(manager.supportsSOCKSProxy(for: type))
#expect(manager.supportsTunnelCommand(for: type))
}

@Test("every transport that forwards a port is offered exactly where SSH is")
func portForwardTransportsFollowSSH() {
for type in DatabaseType.allKnownTypes {
let ssh = manager.supportsSSH(for: type)
#expect(manager.supportsCloudflareTunnel(for: type) == ssh, "Cloudflare Tunnel disagrees for \(type.rawValue)")
#expect(manager.supportsSOCKSProxy(for: type) == ssh, "SOCKS proxy disagrees for \(type.rawValue)")
#expect(manager.supportsTunnelCommand(for: type) == ssh, "tunnel command disagrees for \(type.rawValue)")
}
}
}
2 changes: 1 addition & 1 deletion TableProTests/Plugins/WeaviateConnectionFieldsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ struct WeaviateRegistrySnapshotTests {
#expect(snapshot.schema.defaultPrimaryKeyColumn == "uuid")
#expect(snapshot.schema.immutableColumns == ["uuid", "vector"])
#expect(!snapshot.supportsForeignKeys)
#expect(!snapshot.capabilities.supportsSSH)
#expect(snapshot.capabilities.supportsSSH)
#expect(snapshot.capabilities.supportsSSL)
#expect(snapshot.connection.category == .document)
#expect(snapshot.iconName == "weaviate-icon")
Expand Down
20 changes: 20 additions & 0 deletions TableProTests/ViewModels/ConnectionFormTransportTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,26 @@ struct ConnectionFormTransportTests {
}
}

@Test("Weaviate offers the SSH tunnel and every other transport that forwards a port")
func weaviateOffersPortForwards() {
let available = coordinator(type: .weaviate).availableTransports
#expect(available == [nil, .ssh, .cloudflare, .socksProxy, .tunnelCommand])
}

/// The built-in entry of a registry type is what the form reads until its plugin is installed,
/// and BigQuery or DuckDB offered Cloudflare Tunnel from it with nothing to forward to.
@Test("a type with no port forward and no remote file connects directly only")
func typeWithoutPortForwardIsDirectOnly() {
let manager = PluginManager.shared
for type in DatabaseType.allKnownTypes
where !manager.supportsSSH(for: type) && !manager.supportsRemoteDatabaseFile(for: type) {
#expect(
coordinator(type: type).availableTransports == [nil],
"\(type.rawValue) offers a transport it has no port to forward through"
)
}
}

@Test("changing the database type returns the connection to direct")
func typeChangeResetsTransport() {
let coordinator = coordinator()
Expand Down
6 changes: 3 additions & 3 deletions docs/connections/connection-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,9 @@ Metadata connections are the extra ones opened to read a database's object list
| [Cassandra / ScyllaDB](/databases/cassandra) | 9042 | Yes | Yes | Yes | No | Yes | Yes |
| [etcd](/databases/etcd) | 2379 | Yes | Yes | Yes | No | Yes | Yes |
| [SurrealDB](/databases/surrealdb) | 8000 | Yes | Yes | Yes | No | Yes | Yes |
| [Elasticsearch](/databases/elasticsearch) | 9200 | No | Yes | No | No | No | No |
| [Typesense](/databases/typesense) | 8108 | No | Yes | No | No | No | No |
| [Weaviate](/databases/weaviate) | 8080 | No | Yes | No | No | No | No |
| [Elasticsearch](/databases/elasticsearch) | 9200 | Yes | Yes | Yes | No | Yes | Yes |
| [Typesense](/databases/typesense) | 8108 | Yes | Yes | Yes | No | Yes | Yes |
| [Weaviate](/databases/weaviate) | 8080 | Yes | Yes | Yes | No | Yes | Yes |
| [SAP HANA](/databases/sap-hana) | 443 | No | Yes | No | No | No | No |
| [Snowflake](/databases/snowflake) | 443 | No | No | No | No | No | No |
| [SQLite](/databases/sqlite) | File | No | No | No | No | No | No |
Expand Down
2 changes: 1 addition & 1 deletion docs/connections/ssh-tunneling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ To share one SSH config across connections, save it with **Save Current as Profi
<img className="hidden dark:block" src="/images/ssh-tunnel-config-dark.png" alt="Network section with SSH Tunnel selected and a saved profile in the Profile picker" />
</Frame>

**SSH Tunnel** is not offered on SQLite, PGlite, libSQL, Beancount, BigQuery, Spanner, Cloudflare D1, Cloudflare R2 SQL, DynamoDB, Elasticsearch, Typesense, Weaviate, or Snowflake: each is reached over a local file, a loopback socket, or a vendor HTTP API.
**SSH Tunnel** is not offered on SQLite, DuckDB, PGlite, libSQL, Turso, Beancount, BigQuery, Spanner, Cloudflare D1, Cloudflare R2 SQL, DynamoDB, or Snowflake: each is reached over a local file, a loopback socket, or a vendor HTTP API. For SAP HANA, forward the port yourself, as its [Limitations](/databases/sap-hana#limitations) show.

## Authentication methods

Expand Down
4 changes: 3 additions & 1 deletion docs/databases/elasticsearch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Click **New Connection…**, select **Elasticsearch**, enter host and port, pick

The form shows no Database field: a connection reaches one cluster, and its indices are the objects.

For a node on a private server, open the **Network** tab and set **Connect via** to **SSH Tunnel**, then set **Host** to the address Elasticsearch listens on as the SSH server sees it, usually `localhost`. See [SSH Tunneling](/connections/ssh-tunneling).

## Connection settings

| Field | Description |
Expand Down Expand Up @@ -79,7 +81,7 @@ New connections start on **Disabled**, plain HTTP with no fallback to anything e
- Paging past 10,000 documents switches to `search_after` over a point-in-time. That threshold is fixed, so an index with a lowered `max_result_window` errors before the switch; raise the index setting back to 10,000.
- An array or object cell is cut at 10,000 characters and ends in `...`. Saving an edit to a cut cell stores the fragment; change long values in the console.
- Truncate is not offered. The nearest thing Elasticsearch has is `_delete_by_query`, which runs asynchronously and reports conflicts per document. Run it in the console.
- No mapping or schema editing, no transactions, no import, no [SSH tunnel](/connections/ssh-tunneling).
- No mapping or schema editing, no transactions, no import.
- OpenSearch is not supported.

## Troubleshooting
Expand Down
3 changes: 2 additions & 1 deletion docs/databases/typesense.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Click **New Connection…**, select **Typesense**, enter host, port and the **AP

There is no Database field. One connection reaches one cluster and its collections are the objects.

For a node on a private server, open the **Network** tab and set **Connect via** to **SSH Tunnel**, then set **Host** to the address Typesense listens on as the SSH server sees it, usually `localhost`. See [SSH Tunneling](/connections/ssh-tunneling).

## Connection settings

| Field | Description |
Expand Down Expand Up @@ -139,7 +141,6 @@ The connection's SSL mode picks the scheme: **Disabled** talks HTTP, anything el
- No import. Load documents with `POST /collections/:name/documents/import` in the console.
- is NULL, is not NULL, is empty, is not empty, matches regex and ends with are refused in the filter bar. Ends with needs a field created with `infix: true`, which the filter bar cannot reach.
- A filter value containing a backtick is refused. Match on a value without one, or write the filter in the **Raw SQL** column.
- No [SSH tunnel](/connections/ssh-tunneling), Cloudflare Tunnel, SOCKS proxy or tunnel command. Reach a private node through a reverse proxy.
- The console takes a method and a path, not SQL. `DELETE FROM books` looks like an HTTP verb and a path, so it is refused rather than sent.

## Troubleshooting
Expand Down
3 changes: 2 additions & 1 deletion docs/databases/weaviate.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Click **New Connection…**, select **Weaviate**, enter host and port, pick an *

There is no Database field. One connection reaches one Weaviate instance, and its collections are the objects.

For a node on a private server, open the **Network** tab and set **Connect via** to **SSH Tunnel**, then set **Host** to the address Weaviate listens on as the SSH server sees it, usually `localhost`. See [SSH Tunneling](/connections/ssh-tunneling).

## Connection settings

| Field | Description |
Expand Down Expand Up @@ -109,7 +111,6 @@ New connections start on **Disabled**, plain HTTP. Every other mode goes over HT
- Cross-references are properties, not foreign keys. There are no routines, triggers, or schema edits from Structure.
- Multi-tenancy is not exposed. Name a tenant in GraphQL if the collection requires one.
- Paging stops at row 10,000. Weaviate refuses an offset and limit that add up past `QUERY_MAXIMUM_RESULTS`, which defaults to 10,000.
- No [SSH tunnel](/connections/ssh-tunneling).
- The plugin is registry-only. It is not on iPhone or iPad.

## Troubleshooting
Expand Down
Loading