From 8e56234ceab248fe5327a28ac2b20f4dccae3020 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 21 Jul 2026 22:27:40 +0700 Subject: [PATCH] feat(plugins): add PGlite support over the PostgreSQL socket protocol Claude-Session: https://claude.ai/code/session_017Z4znQFPkXHLS6CyVdShx4 --- CHANGELOG.md | 1 + .../LibPQDriverCore.swift | 8 +- .../LibPQPluginConnection.swift | 36 ++++---- .../PGlitePluginDriver.swift | 54 +++++++++++ .../PostgreSQLPlugin.swift | 8 +- .../PostgreSQLPluginDriver.swift | 6 +- .../Plugins/PluginManager+Registration.swift | 7 +- .../Core/Plugins/PluginMetadataRegistry.swift | 89 ++++++++++++++++++- .../Query/MetadataConnectionPool.swift | 13 ++- .../Connection/DatabaseConnection.swift | 10 +++ .../ViewModels/NetworkPaneViewModel.swift | 5 +- .../PluginMetadataRegistryVariantTests.swift | 50 +++++++++++ .../Models/DatabaseTypePGliteTests.swift | 66 ++++++++++++++ docs/databases/overview.mdx | 3 +- docs/databases/pglite.mdx | 63 +++++++++++++ docs/docs.json | 1 + docs/index.mdx | 5 +- 17 files changed, 387 insertions(+), 38 deletions(-) create mode 100644 Plugins/PostgreSQLDriverPlugin/PGlitePluginDriver.swift create mode 100644 TableProTests/Core/Plugins/PluginMetadataRegistryVariantTests.swift create mode 100644 TableProTests/Models/DatabaseTypePGliteTests.swift create mode 100644 docs/databases/pglite.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index 902bdd430..038314ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Connect to PGlite through its socket server (`pglite-server`), with schema browsing, SQL editing, and row editing like PostgreSQL. Defaults to a loopback host with no TLS, and treats the connection as single-use to match PGlite's one-connection limit. (#1911) - In the CSV editor, right-click a column header to rename, insert, delete, or change the type of that column. (#1913) ### Fixed diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift index 27feb02e0..1d2e47cd4 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift @@ -12,6 +12,7 @@ import TableProPluginKit final class LibPQDriverCore: @unchecked Sendable { private let config: DriverConnectionConfig private let schemaFallbackQueries: [String] + private let singleConnectionMode: Bool private var libpqConnection: LibPQPluginConnection? var currentSchema: String = "public" @@ -24,10 +25,12 @@ final class LibPQDriverCore: @unchecked Sendable { init( config: DriverConnectionConfig, - schemaFallbackQueries: [String] = PostgreSQLSchemaQueries.schemaFallbackQueries + schemaFallbackQueries: [String] = PostgreSQLSchemaQueries.schemaFallbackQueries, + singleConnectionMode: Bool = false ) { self.config = config self.schemaFallbackQueries = schemaFallbackQueries + self.singleConnectionMode = singleConnectionMode } // MARK: - Connection @@ -40,7 +43,8 @@ final class LibPQDriverCore: @unchecked Sendable { password: config.password.isEmpty ? nil : config.password, database: config.database, sslConfig: config.ssl, - options: config.additionalFields["connectionOptions"] + options: config.additionalFields["connectionOptions"], + suppressServerSideCancel: singleConnectionMode ) try await pqConn.connect() diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift index a2b0fe023..4536c2a89 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift @@ -99,6 +99,7 @@ final class LibPQPluginConnection: @unchecked Sendable { private let database: String private let sslConfig: SSLConfiguration private let options: String? + private let suppressServerSideCancel: Bool private let stateLock = NSLock() private var _isConnected: Bool = false @@ -135,7 +136,8 @@ final class LibPQPluginConnection: @unchecked Sendable { password: String?, database: String, sslConfig: SSLConfiguration = SSLConfiguration(), - options: String? = nil + options: String? = nil, + suppressServerSideCancel: Bool = false ) { self.host = host self.port = port @@ -144,6 +146,7 @@ final class LibPQPluginConnection: @unchecked Sendable { self.database = database self.sslConfig = sslConfig self.options = options + self.suppressServerSideCancel = suppressServerSideCancel } deinit { @@ -358,7 +361,7 @@ final class LibPQPluginConnection: @unchecked Sendable { let currentConn = conn stateLock.unlock() - guard let currentConn else { return } + guard let currentConn, !suppressServerSideCancel else { return } let cancelObj = PQgetCancel(currentConn) guard let cancelObj else { return } defer { PQfreeCancel(cancelObj) } @@ -555,9 +558,22 @@ final class LibPQPluginConnection: @unchecked Sendable { // MARK: - Streaming Query + private static func cancelAndDrain(_ conn: OpaquePointer, suppressCancel: Bool) { + if !suppressCancel { + let cancelObj = PQgetCancel(conn) + if let cancelObj { + var errbuf = [CChar](repeating: 0, count: 256) + PQcancel(cancelObj, &errbuf, Int32(errbuf.count)) + PQfreeCancel(cancelObj) + } + } + while let res = PQgetResult(conn) { PQclear(res) } + } + func streamQuery(_ query: String) -> AsyncThrowingStream { let queryToRun = String(query) let queue = self.queue + let suppressCancel = suppressServerSideCancel final class StreamState: @unchecked Sendable { var conn: OpaquePointer? @@ -583,13 +599,7 @@ final class LibPQPluginConnection: @unchecked Sendable { streamState.drained = true streamState.lock.unlock() guard let conn, !alreadyDrained else { return } - let cancelObj = PQgetCancel(conn) - if let cancelObj { - var errbuf = [CChar](repeating: 0, count: 256) - PQcancel(cancelObj, &errbuf, Int32(errbuf.count)) - PQfreeCancel(cancelObj) - } - while let res = PQgetResult(conn) { PQclear(res) } + Self.cancelAndDrain(conn, suppressCancel: suppressCancel) } } @@ -703,13 +713,7 @@ final class LibPQPluginConnection: @unchecked Sendable { if !batch.isEmpty { continuation.yield(.rows(batch)) } - let cancelObj = PQgetCancel(conn) - if let cancelObj { - var errbuf = [CChar](repeating: 0, count: 256) - PQcancel(cancelObj, &errbuf, Int32(errbuf.count)) - PQfreeCancel(cancelObj) - } - while let res = PQgetResult(conn) { PQclear(res) } + Self.cancelAndDrain(conn, suppressCancel: suppressCancel) streamState.lock.lock() streamState.drained = true streamState.lock.unlock() diff --git a/Plugins/PostgreSQLDriverPlugin/PGlitePluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PGlitePluginDriver.swift new file mode 100644 index 000000000..ef396a2f8 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PGlitePluginDriver.swift @@ -0,0 +1,54 @@ +// +// PGlitePluginDriver.swift +// PostgreSQLDriverPlugin +// +// PGlite PluginDatabaseDriver implementation. PGlite is PostgreSQL 17 compiled to +// WASM, reached over its socket server (@electric-sql/pglite-socket), so it reuses +// PostgreSQLPluginDriver's introspection. It is single-connection: query cancellation +// is a protocol no-op and a second connection is refused, so this driver suppresses +// the wire-level cancel and drops the cancelQuery capability. +// + +import Foundation +import TableProPluginKit + +final class PGlitePluginDriver: PostgreSQLPluginDriver { + private let connectHost: String + private let connectPort: Int + + init(config: DriverConnectionConfig) { + self.connectHost = config.host + self.connectPort = config.port + super.init(config: config, singleConnectionMode: true) + } + + override var capabilities: PluginCapabilities { + super.capabilities.subtracting(.cancelQuery) + } + + override func connect() async throws { + do { + try await super.connect() + } catch is CancellationError { + throw CancellationError() + } catch { + throw Self.connectError(underlying: error, host: connectHost, port: connectPort) + } + } + + private static func connectError(underlying: Error, host: String, port: Int) -> Error { + let reason = (underlying as? LibPQPluginError)?.message ?? underlying.localizedDescription + let template = String( + localized: "Can't reach a PGlite socket server at %@:%d. Start it with 'npx @electric-sql/pglite-socket', then try again." + ) + return PGliteConnectionError( + pluginErrorMessage: String(format: template, host, port), + pluginErrorDetail: reason.isEmpty ? nil : reason + ) + } +} + +struct PGliteConnectionError: PluginDriverError { + let pluginErrorMessage: String + let pluginErrorDetail: String? +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift index 6c0a673c8..790825dda 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift @@ -14,13 +14,13 @@ import TableProPluginKit final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let pluginName = "PostgreSQL Driver" static let pluginVersion = "1.0.0" - static let pluginDescription = "PostgreSQL, Redshift, and CockroachDB support via libpq" + static let pluginDescription = "PostgreSQL, Redshift, CockroachDB, and PGlite support via libpq" static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "PostgreSQL" static let databaseDisplayName = "PostgreSQL" static let iconName = "postgresql-icon" - static let defaultPort = 5432 + static let defaultPort = 5_432 static let additionalConnectionFields: [ConnectionField] = [ ConnectionField( id: "usePgpass", @@ -88,7 +88,7 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { section: .advanced ) ] - static let additionalDatabaseTypeIds: [String] = ["Redshift", "CockroachDB"] + static let additionalDatabaseTypeIds: [String] = ["Redshift", "CockroachDB", "PGlite"] // MARK: - UI/Capability Metadata @@ -176,6 +176,7 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { case "PostgreSQL": return "PostgreSQL" case "Redshift": return "Redshift" case "CockroachDB": return "CockroachDB" + case "PGlite": return "PGlite" default: return nil } } @@ -185,6 +186,7 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { switch variant { case "Redshift": return RedshiftPluginDriver(config: config) case "CockroachDB": return CockroachPluginDriver(config: config) + case "PGlite": return PGlitePluginDriver(config: config) default: return PostgreSQLPluginDriver(config: config) } } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 1e3d27bc8..9e6ac8e09 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -10,7 +10,7 @@ import Foundation import os import TableProPluginKit -final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { +class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { let core: LibPQDriverCore private static let logger = Logger(subsystem: "com.TablePro.PostgreSQLDriver", category: "PostgreSQLPluginDriver") @@ -41,8 +41,8 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { ] } - init(config: DriverConnectionConfig) { - self.core = LibPQDriverCore(config: config) + init(config: DriverConnectionConfig, singleConnectionMode: Bool = false) { + self.core = LibPQDriverCore(config: config, singleConnectionMode: singleConnectionMode) } // MARK: - Connection diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index 3ac52eac9..b73dbf9c6 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -43,12 +43,7 @@ extension PluginManager { } PluginMetadataRegistry.shared.register(snapshot: snapshot, forTypeId: typeId, preserveIcon: true) for additionalId in driverType.additionalDatabaseTypeIds { - var additionalSnapshot = snapshot - if let existingDefault = PluginMetadataRegistry.shared.snapshot(forTypeId: additionalId), - !existingDefault.explainVariants.isEmpty { - additionalSnapshot = snapshot.withExplainVariants(existingDefault.explainVariants) - } - PluginMetadataRegistry.shared.register(snapshot: additionalSnapshot, forTypeId: additionalId, preserveIcon: true) + PluginMetadataRegistry.shared.registerVariant(pluginSnapshot: snapshot, forTypeId: additionalId) PluginMetadataRegistry.shared.registerTypeAlias(additionalId, primaryTypeId: typeId) } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index bff747d0b..e5ddbafd2 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -61,6 +61,7 @@ struct PluginMetadataSnapshot: Sendable { var supportsOpportunisticTLS: Bool = true var supportsCloudflareTunnel: Bool = true var supportsClientKeyPassphrase: Bool = false + var supportsConnectionPooling: Bool = true var supportsSOCKSProxy: Bool { supportsSSH } @@ -172,19 +173,22 @@ struct PluginMetadataSnapshot: Sendable { let tagline: String let hidesBuiltInPassword: Bool let defaultUnixSocketPath: String? + let defaultHost: String? init( additionalConnectionFields: [ConnectionField] = [], category: DatabaseCategory = .other, tagline: String = "", hidesBuiltInPassword: Bool = false, - defaultUnixSocketPath: String? = nil + defaultUnixSocketPath: String? = nil, + defaultHost: String? = nil ) { self.additionalConnectionFields = additionalConnectionFields self.category = category self.tagline = tagline self.hidesBuiltInPassword = hidesBuiltInPassword self.defaultUnixSocketPath = defaultUnixSocketPath + self.defaultHost = defaultHost } static let defaults = ConnectionConfig() @@ -765,6 +769,62 @@ final class PluginMetadataRegistry: @unchecked Sendable { tagline: String(localized: "Distributed SQL, PostgreSQL-compatible") ) )), + ("PGlite", PluginMetadataSnapshot( + displayName: "PGlite", iconName: "postgresql-icon", defaultPort: 5_432, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: false, primaryUrlScheme: "pglite", parameterStyle: .dollar, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["pglite"], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#F4B942", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: true, + supportsExport: true, + supportsSSH: false, + supportsSSL: false, + supportsCascadeDrop: true, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: true, + supportsDropDatabase: true, + supportsRenameColumn: true, + supportsTriggers: true, + supportsTriggerEditing: true, + defaultSSLMode: .disabled, + supportsCloudflareTunnel: false, + supportsConnectionPooling: false + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: ["postgres", "template0", "template1"], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .bySchema, + structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: postgresqlDialect, + statementCompletions: [], + columnTypesByCategory: postgresqlColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [], + category: .relational, + tagline: String(localized: "Embedded WASM Postgres over a socket server"), + hidesBuiltInPassword: true, + defaultHost: "127.0.0.1" + ) + )), ("SQLite", PluginMetadataSnapshot( displayName: "SQLite", iconName: "sqlite-icon", defaultPort: 0, requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true, @@ -831,6 +891,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { reverseTypeIndex["MariaDB"] = "MySQL" reverseTypeIndex["Redshift"] = "PostgreSQL" reverseTypeIndex["CockroachDB"] = "PostgreSQL" + reverseTypeIndex["PGlite"] = "PostgreSQL" reverseTypeIndex["ScyllaDB"] = "Cassandra" reverseTypeIndex["Turso"] = "libSQL" } @@ -838,6 +899,29 @@ final class PluginMetadataRegistry: @unchecked Sendable { func register(snapshot: PluginMetadataSnapshot, forTypeId typeId: String, preserveIcon: Bool = false) { lock.lock() defer { lock.unlock() } + registerLocked(snapshot: snapshot, forTypeId: typeId, preserveIcon: preserveIcon) + } + + /// Registers an additional database type served by a multi-type plugin (Redshift, + /// CockroachDB, PGlite on the PostgreSQL plugin). A plugin's statics are per-class, so + /// they cannot express per-type facts like PGlite's disabled SSL or single-connection limit. + /// The curated built-in entry is therefore authoritative for a variant; the plugin only + /// fills the EXPLAIN variants the curated entry leaves open. A variant with no curated entry + /// falls back to deriving its snapshot from the plugin. + func registerVariant(pluginSnapshot: PluginMetadataSnapshot, forTypeId typeId: String) { + lock.lock() + defer { lock.unlock() } + guard let curated = defaultSnapshots[typeId] else { + registerLocked(snapshot: pluginSnapshot, forTypeId: typeId, preserveIcon: true) + return + } + let resolved = curated.explainVariants.isEmpty && !pluginSnapshot.explainVariants.isEmpty + ? curated.withExplainVariants(pluginSnapshot.explainVariants) + : curated + registerLocked(snapshot: resolved, forTypeId: typeId, preserveIcon: false) + } + + private func registerLocked(snapshot: PluginMetadataSnapshot, forTypeId typeId: String, preserveIcon: Bool) { var resolved = snapshot if preserveIcon, let existing = snapshots[typeId] { resolved = resolved.withBranding(from: existing) @@ -1030,7 +1114,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { tagline: existingSnapshot?.connection.tagline ?? Self.fallbackTagline(forTypeId: driverType.databaseTypeId), hidesBuiltInPassword: existingSnapshot?.connection.hidesBuiltInPassword ?? false, - defaultUnixSocketPath: existingSnapshot?.connection.defaultUnixSocketPath + defaultUnixSocketPath: existingSnapshot?.connection.defaultUnixSocketPath, + defaultHost: existingSnapshot?.connection.defaultHost ) ) } diff --git a/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/TablePro/Core/Services/Query/MetadataConnectionPool.swift index 3c192f060..51c2e0b93 100644 --- a/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -25,13 +25,15 @@ final class MetadataConnectionPool { @MainActor private final class Entry { let driver: DatabaseDriver + let ownsDriver: Bool var lastUsed: Date var inFlightCount: Int var closeWhenIdle: Bool private var tail: Task = Task {} - init(driver: DatabaseDriver) { + init(driver: DatabaseDriver, ownsDriver: Bool = true) { self.driver = driver + self.ownsDriver = ownsDriver self.lastUsed = Date() self.inFlightCount = 0 self.closeWhenIdle = false @@ -86,13 +88,14 @@ final class MetadataConnectionPool { private func releaseEntry(_ entry: Entry) { entry.inFlightCount -= 1 - if entry.inFlightCount == 0, entry.closeWhenIdle { + if entry.inFlightCount == 0, entry.closeWhenIdle, entry.ownsDriver { entry.driver.disconnect() } } private func closeOrDeferEntry(forKey key: Key) { guard let entry = entries.removeValue(forKey: key) else { return } + guard entry.ownsDriver else { return } if entry.inFlightCount == 0 { entry.driver.disconnect() } else { @@ -106,6 +109,12 @@ final class MetadataConnectionPool { schema: String?, workload: Workload ) async throws -> Entry { + if let session = DatabaseManager.shared.session(for: connectionId), + session.connection.type.supportsConnectionPooling == false { + guard session.driver.status == .connected else { throw DatabaseError.notConnected } + return Entry(driver: session.driver, ownsDriver: false) + } + let key = Key(connectionId: connectionId, database: database, schema: schema, workload: workload) if let entry = entries[key], entry.driver.status == .connected { return entry diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 425efb4c7..0ee90d615 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -29,6 +29,7 @@ extension DatabaseType { static let sqlite = DatabaseType(rawValue: "SQLite") static let redshift = DatabaseType(rawValue: "Redshift") static let cockroachdb = DatabaseType(rawValue: "CockroachDB") + static let pglite = DatabaseType(rawValue: "PGlite") // Registry-distributed types (known plugins, downloadable separately) static let mongodb = DatabaseType(rawValue: "MongoDB") @@ -117,6 +118,14 @@ extension DatabaseType { PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.capabilities.supportsClientKeyPassphrase ?? false } + var supportsConnectionPooling: Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.capabilities.supportsConnectionPooling ?? true + } + + var defaultHost: String? { + PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.connection.defaultHost + } + var supportsCloudSQLProxy: Bool { switch rawValue { case "MySQL", "PostgreSQL", "SQL Server": @@ -182,6 +191,7 @@ extension DatabaseType { case "PostgreSQL": Color(hex: "336791") case "Redshift": Color(hex: "527FFF") case "CockroachDB": Color(hex: "6933FF") + case "PGlite": Color(hex: "F4B942") case "SQLite": Color(hex: "0F80CC") case "SQL Server": Color(hex: "CC2927") case "Oracle": Color(hex: "C74634") diff --git a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift index 00b59c446..53507e4a1 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift @@ -49,7 +49,7 @@ final class NetworkPaneViewModel { } var resolvedHost: String { - host.trimmingCharacters(in: .whitespaces).isEmpty ? "localhost" : host + host.trimmingCharacters(in: .whitespaces).isEmpty ? (type.defaultHost ?? "localhost") : host } var resolvedPort: Int { @@ -94,6 +94,9 @@ final class NetworkPaneViewModel { func applyTypeDefaults(forNewType newType: DatabaseType) { port = String(newType.defaultPort) + if host.trimmingCharacters(in: .whitespaces).isEmpty, let defaultHost = newType.defaultHost { + host = defaultHost + } var values: [String: String] = [:] for field in PluginManager.shared.additionalConnectionFields(for: newType) where field.section == .connection diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistryVariantTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistryVariantTests.swift new file mode 100644 index 000000000..f9b6f9e8e --- /dev/null +++ b/TableProTests/Core/Plugins/PluginMetadataRegistryVariantTests.swift @@ -0,0 +1,50 @@ +// +// PluginMetadataRegistryVariantTests.swift +// TableProTests +// +// A multi-type plugin (PostgreSQL serves Redshift, CockroachDB, PGlite) has one set of +// Swift statics, so per-type facts live only in the curated built-in table. registerVariant +// must keep the curated entry rather than overwrite it with the shared plugin snapshot. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("PluginMetadataRegistry variant registration", .serialized) +struct PluginMetadataRegistryVariantTests { + @Test("keeps the curated port instead of the shared plugin's") + func keepsCuratedPort() throws { + let registry = PluginMetadataRegistry.shared + let postgres = try #require(registry.snapshot(forTypeId: "PostgreSQL")) + #expect(postgres.defaultPort == 5_432) + + registry.registerVariant(pluginSnapshot: postgres, forTypeId: "CockroachDB") + + #expect(registry.snapshot(forTypeId: "CockroachDB")?.defaultPort == 26_257) + } + + @Test("keeps curated capabilities instead of the shared plugin's") + func keepsCuratedCapabilities() throws { + let registry = PluginMetadataRegistry.shared + let postgres = try #require(registry.snapshot(forTypeId: "PostgreSQL")) + #expect(postgres.capabilities.supportsAddColumn == true) + + registry.registerVariant(pluginSnapshot: postgres, forTypeId: "CockroachDB") + + #expect(registry.snapshot(forTypeId: "CockroachDB")?.capabilities.supportsAddColumn == false) + } + + @Test("keeps PGlite's single-connection flag through registration") + func keepsPGliteSingleConnection() throws { + let registry = PluginMetadataRegistry.shared + let postgres = try #require(registry.snapshot(forTypeId: "PostgreSQL")) + #expect(postgres.capabilities.supportsConnectionPooling == true) + + registry.registerVariant(pluginSnapshot: postgres, forTypeId: "PGlite") + + #expect(registry.snapshot(forTypeId: "PGlite")?.capabilities.supportsConnectionPooling == false) + #expect(registry.snapshot(forTypeId: "PGlite")?.connection.defaultHost == "127.0.0.1") + } +} diff --git a/TableProTests/Models/DatabaseTypePGliteTests.swift b/TableProTests/Models/DatabaseTypePGliteTests.swift new file mode 100644 index 000000000..eefb4d999 --- /dev/null +++ b/TableProTests/Models/DatabaseTypePGliteTests.swift @@ -0,0 +1,66 @@ +// +// DatabaseTypePGliteTests.swift +// TableProTests +// +// Tests for .pglite properties and plugin resolution. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("DatabaseType PGlite") +struct DatabaseTypePGliteTests { + @Test("rawValue is PGlite") + func rawValue() { + #expect(DatabaseType.pglite.rawValue == "PGlite") + } + + @Test("defaultPort is 5432") + func defaultPort() { + #expect(DatabaseType.pglite.defaultPort == 5_432) + } + + @Test("defaultHost is loopback IPv4") + func defaultHost() { + #expect(DatabaseType.pglite.defaultHost == "127.0.0.1") + } + + @Test("SSL is disabled by default (socket server has no TLS)") + func defaultSSLModeDisabled() { + #expect(DatabaseType.pglite.defaultSSLMode == .disabled) + } + + @Test("connection pooling is off (single connection)") + func doesNotPool() { + #expect(DatabaseType.pglite.supportsConnectionPooling == false) + } + + @Test("PostgreSQL still pools (default unchanged)") + func postgresStillPools() { + #expect(DatabaseType.postgresql.supportsConnectionPooling == true) + } + + @Test("iconName reuses the PostgreSQL icon") + func iconName() { + #expect(DatabaseType.pglite.iconName == "postgresql-icon") + } + + @Test("pluginTypeId resolves to PostgreSQL") + func pluginTypeIdResolvesToPostgres() { + #expect(DatabaseType.pglite.pluginTypeId == "PostgreSQL") + } + + @Test("Codable round-trips through rawValue") + func codableRoundTrip() throws { + let encoded = try JSONEncoder().encode(DatabaseType.pglite) + let decoded = try JSONDecoder().decode(DatabaseType.self, from: encoded) + #expect(decoded == DatabaseType.pglite) + } + + @Test("allKnownTypes contains pglite") + func allKnownTypesContainsPGlite() { + #expect(DatabaseType.allKnownTypes.contains(.pglite)) + } +} diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index 3410cb98b..805f97e61 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -5,7 +5,7 @@ description: Create, organize, and switch database connections, with health moni # Managing Connections -TablePro connects to 22 databases through its plugin system. This page covers creating and organizing connections. Driver-specific fields and quirks live on each database's own page. +TablePro connects to 23 databases through its plugin system. This page covers creating and organizing connections. Driver-specific fields and quirks live on each database's own page. ## Supported Databases @@ -16,6 +16,7 @@ TablePro connects to 22 databases through its plugin system. This page covers cr | [PostgreSQL](/databases/postgresql) | 5432 | Yes | Yes | Yes | Yes | Yes | | [Amazon Redshift](/databases/redshift) | 5439 | Yes | Yes | Yes | No | Yes | | [CockroachDB](/databases/cockroachdb) | 26257 | Yes | Yes | Yes | No | Yes | +| [PGlite](/databases/pglite) | 5432 | No | No | No | No | No | | [Microsoft SQL Server](/databases/mssql) | 1433 | Yes | Yes | Yes | Yes | Yes | | [Oracle](/databases/oracle) | 1521 | Yes | Yes | Yes | No | Yes | | [ClickHouse](/databases/clickhouse) | 8123 | Yes | Yes | Yes | No | Yes | diff --git a/docs/databases/pglite.mdx b/docs/databases/pglite.mdx new file mode 100644 index 000000000..08f265bb1 --- /dev/null +++ b/docs/databases/pglite.mdx @@ -0,0 +1,63 @@ +--- +title: PGlite +description: Connect to a PGlite database over its socket server using the PostgreSQL wire protocol +--- + +# PGlite Connections + +TablePro connects to [PGlite](https://pglite.dev), a build of PostgreSQL 17 compiled to WebAssembly. PGlite runs in-process in JavaScript, so it speaks the network wire protocol only through its socket server, `@electric-sql/pglite-socket`. Once that server is running, TablePro connects to it with the same libpq driver as [PostgreSQL](/databases/postgresql), and schema introspection, DDL, and EXPLAIN all behave like PostgreSQL. + +## Start the socket server + +PGlite has no server of its own, so start one first. It listens on `127.0.0.1:5432` by default: + +```bash +# In-memory database, gone when the server stops +npx @electric-sql/pglite-socket + +# Persist to a data directory +npx @electric-sql/pglite-socket --db=./my-pgdata +``` + +Leave it running while you use the connection. TablePro opens a single connection to PGlite and serializes all of its own background work (schema browsing, autocomplete, row counts) over that one connection, so you do not need to raise the server's `--max-connections`. + +## Connection Settings + +| Field | Default | Notes | +|-------|---------|-------| +| **Host** | `127.0.0.1` | The socket server binds loopback IPv4, not `localhost` | +| **Port** | `5432` | Match the server's `--port` | +| **Database** | `postgres` | The single database PGlite exposes | +| **Username** | `postgres` | PGlite uses trust auth; any username connects | +| **Password** | - | Ignored | + +SSL is off and cannot be enabled: the socket server has no TLS. Fill in the form and click **Save & Connect**. + +## Connection URL + +```text +pglite://postgres@127.0.0.1:5432/postgres +``` + +See [Connection URL Reference](/databases/connection-urls) for all parameters. + +## Features + +**Schemas**: like PostgreSQL, default schema `public`. Switch with Cmd+K. + +**DDL, EXPLAIN, editing**: PGlite is real PostgreSQL 17, so `pg_catalog` and `information_schema` are complete. Table and view definitions, indexes, foreign keys, structure editing, and the visual `EXPLAIN` / `EXPLAIN ANALYZE` plan tree all work as they do for PostgreSQL. + +**Import & Export**: export to CSV, JSON, SQL, or XLSX. Import from JSON or SQL. See [Import & Export](/features/import-export). + +## Limitations + +- **Single connection.** PGlite serves one connection at a time. TablePro is built for this and keeps to one connection, but other tools that open connections in parallel will fail against the same server. +- **No TLS.** The socket server rejects SSL, so SSL Mode is fixed to Disabled and the SSH, Cloudflare Tunnel, and SOCKS panes do not apply. +- **Cancel does nothing.** PGlite has no backend process to signal, so cancelling a running query has no effect at the protocol level. Let long queries finish. +- **No `COPY ... FROM STDIN`.** The socket server does not support it, so avoid import paths that stream a copy; SQL and JSON import work. + +## Troubleshooting + +**Can't reach a PGlite socket server**: the server is not running or is on a different address. Start it with `npx @electric-sql/pglite-socket` and confirm the host and port match. TablePro needs loopback `127.0.0.1`, not `localhost`, which can resolve to IPv6 first. + +**Too many connections**: something else is already connected to the socket server, which allows only one client by default. Close the other client, or start the server with a higher `--max-connections`. diff --git a/docs/docs.json b/docs/docs.json index 3c171eee2..146d8c415 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -54,6 +54,7 @@ "databases/postgresql", "databases/redshift", "databases/cockroachdb", + "databases/pglite", "databases/mssql", "databases/oracle" ] diff --git a/docs/index.mdx b/docs/index.mdx index 6bca38d23..683ab6672 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -5,7 +5,7 @@ description: Native macOS database client for MySQL, PostgreSQL, SQLite, MongoDB # TablePro -Native macOS client for 23 databases. Built with SwiftUI and AppKit, no Electron. The download is about 20 MB. +Native macOS client for 24 databases. Built with SwiftUI and AppKit, no Electron. The download is about 20 MB. TablePro main interface @@ -23,7 +23,7 @@ Native macOS client for 23 databases. Built with SwiftUI and AppKit, no Electron **[Safe Mode](/features/safe-mode)**: 6 per-connection protection levels, from silent alerts to Touch ID and read-only. **[Import & Export](/features/import-export)**: CSV, JSON, SQL, XLSX, MQL. Streaming export for large datasets. **[CSV Inspector](/features/csv-inspector)**: Open `.csv` and `.tsv` files directly. Edit cells, insert and delete rows and columns, save in the original dialect. -**[Plugin System](/features/plugins)**: 5 bundled drivers covering 8 databases, plus 15 more drivers installable from the plugin registry. +**[Plugin System](/features/plugins)**: 5 bundled drivers covering 9 databases, plus 15 more drivers installable from the plugin registry. **[iCloud Sync](/features/icloud-sync)**: Connections, groups, tags, settings, SSH profiles, saved queries and folders, favorite tables, and custom AI slash commands sync across Macs. **[Themes](/customization/appearance)**: Light, dark, and custom editor themes. Per-connection color labels. @@ -37,6 +37,7 @@ Native macOS client for 23 databases. Built with SwiftUI and AppKit, no Electron | SQLite | N/A (file-based) | Built-in | | Amazon Redshift | 5439 | Built-in | | CockroachDB | 26257 | Built-in | +| PGlite | 5432 | Built-in | | Microsoft SQL Server | 1433 | Plugin | | ClickHouse | 8123 | Built-in | | Redis | 6379 | Built-in |