Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
fae307b
fix(ssh): expand tilde in agent socket and identityAgent paths
datlechin Apr 30, 2026
48b9e0e
fix(storage): persist group deletions before firing sync notification
datlechin Apr 30, 2026
3a096bc
fix(sql): throw when database dialect cannot be resolved
datlechin Apr 30, 2026
0b1a39b
docs: add changelog entries for ssh, storage, dialect fixes
datlechin Apr 30, 2026
d57fb83
test: delete dead LIMIT-1 codegen test methods
datlechin Apr 30, 2026
1a5a986
test: update stale icon and SF Symbol expectations
datlechin Apr 30, 2026
77cc6f9
test: add MSSQL plugin stub for unit test bundle
datlechin Apr 30, 2026
be69ff2
test: disable parallel execution in TablePro scheme
datlechin Apr 30, 2026
7a15e36
revert: re-enable parallel test execution
datlechin Apr 30, 2026
efd56ee
refactor(storage): inject UserDefaults and dependencies into GroupSto…
datlechin Apr 30, 2026
c1ac5c9
refactor(storage): inject UserDefaults into AppSettingsStorage
datlechin Apr 30, 2026
e49e3b5
refactor(storage): inject file URL and dependencies into ConnectionSt…
datlechin Apr 30, 2026
9894916
refactor(sync): inject UserDefaults into SyncMetadataStorage
datlechin Apr 30, 2026
849008d
refactor(storage): inject database URL into QueryHistoryStorage
datlechin Apr 30, 2026
b3b3c63
refactor(database): inject storage and plugin manager into DatabaseMa…
datlechin Apr 30, 2026
577bc3e
refactor(plugins): inject plugin search URLs and UserDefaults into Pl…
datlechin Apr 30, 2026
e1088a3
test: rewrite storage tests to use isolated instances
datlechin Apr 30, 2026
9398451
docs: add changelog entry for Apple-pattern singleton refactor
datlechin Apr 30, 2026
dbf11ef
refactor(storage): inject database URL into SQLFavoriteStorage
datlechin Apr 30, 2026
a9960e9
refactor(sync): inject metadata storage into SyncChangeTracker
datlechin Apr 30, 2026
783236c
test: rewrite SQLFavoriteStorage and sync-aware tests with injected i…
datlechin Apr 30, 2026
57197a3
refactor(storage): drop dead test-detection branch from QueryHistoryS…
datlechin Apr 30, 2026
83aaf13
docs: add changelog entry for SQLFavoriteStorage and SyncChangeTracke…
datlechin Apr 30, 2026
0baaaa9
fix(database): expose injected dependencies as internal for cross-fil…
datlechin Apr 30, 2026
5a04dd8
fix(storage): persist connection deletions before firing sync notific…
datlechin Apr 30, 2026
cc4040a
docs: add changelog entry for connection delete fix and tighten stora…
datlechin Apr 30, 2026
b2df379
docs: correct sync delete ordering invariant
datlechin Apr 30, 2026
fc9e0de
style(plugins): add explicit internal access modifier to userPluginsDir
datlechin Apr 30, 2026
a3af3df
refactor(storage): convert SQLFavoriteStorage to actor
datlechin Apr 30, 2026
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Storage and sync singletons accept dependencies via init for test isolation, matching Apple's URLSession and UserDefaults convention. Production callers using `.shared` are unchanged. `SQLFavoriteStorage` is now an actor so its first access no longer blocks the main thread on SQLite setup.
- Create Database dialog is now driver-driven. Each driver discovers its own valid options (PostgreSQL queries `pg_collation` and `pg_database`, MySQL/MariaDB query `information_schema.character_sets`/`collations`). The hardcoded macOS-flavored locale list is gone. Engines that don't support creation hide the Create button instead of failing on click.
- Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. No callers migrated yet (Phase C.1 of the DataGrid refactor).
- DataGrid columns and cells refactored to use a persistent column pool and typed cell view hierarchy. CPU usage on table switch reduced significantly through proper NSTableView reuse pool retention.
Expand Down Expand Up @@ -72,6 +73,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Native Search Field focus regression when clearing text
- PostgreSQL Create Database failed with `new collation incompatible with template database` on glibc-initialized servers (#927). Encodings, collations, and the `template1` defaults are now read from the server. `LC_CTYPE` mirrors `LC_COLLATE`, and `TEMPLATE template0` is added automatically when the chosen collation differs from `template1.datcollate`.
- Redshift Create Database emitted PostgreSQL `LC_COLLATE` syntax which is invalid Redshift grammar. Now emits `COLLATE { CASE_SENSITIVE | CASE_INSENSITIVE }`.
- Expand tilde in SSH agent socket and `IdentityAgent` paths so 1Password and similar agents work when configured with `~/...` paths.
- Persist group deletions before firing the sync notification, fixing a race that could re-upload deleted groups via iCloud.
- Persist connection deletions before firing the sync notification, fixing the same race for deleted connections.
- Refuse to generate SQL when the database dialect cannot be resolved, instead of silently emitting unquoted identifiers.

## [0.36.0] - 2026-04-27

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ When adding a new method to the driver protocol: add to `PluginDatabaseDriver` (

These have caused real bugs when violated:

**Sync delete ordering**: In `ConnectionStorage` (and all storage classes), `SyncChangeTracker.markDeleted()` must be called BEFORE `saveConnections()`. The `markDeleted` call fires `postChangeNotification` which can trigger a sync — if `saveConnections` hasn't run yet, the file still has the deleted item and sync may re-add it.
**Sync delete ordering**: In `ConnectionStorage` (and all storage classes), `SyncChangeTracker.markDeleted()` must be called AFTER `saveConnections()`. The `markDeleted` call fires `postChangeNotification` which can trigger a sync. If the file on disk still contains the deleted item when sync runs, it may re-upload the deleted record. Persist first, then notify.

**WelcomeViewModel tree rebuild**: The welcome screen renders `treeItems` (grouped/filtered), not `connections` directly. Every mutation to `connections` must call `rebuildTree()` afterward, or the UI won't update.

Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/ChangeTracking/DataChangeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ final class DataChangeManager: ChangeManaging {
)
}

let generator = SQLStatementGenerator(
let generator = try SQLStatementGenerator(
tableName: tableName,
columns: columns,
primaryKeyColumns: primaryKeyColumns,
Expand Down
9 changes: 7 additions & 2 deletions TablePro/Core/ChangeTracking/SQLStatementGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,18 @@ struct SQLStatementGenerator {
parameterStyle: ParameterStyle? = nil,
dialect: SQLDialectDescriptor? = nil,
quoteIdentifier: ((String) -> String)? = nil
) {
) throws {
self.tableName = tableName
self.columns = columns
self.primaryKeyColumns = primaryKeyColumns
self.databaseType = databaseType
self.parameterStyle = parameterStyle ?? Self.defaultParameterStyle(for: databaseType)
self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(dialect)
if let quoteIdentifier {
self.quoteIdentifierFn = quoteIdentifier
} else {
let resolvedDialect = try resolveSQLDialect(for: databaseType, explicit: dialect)
self.quoteIdentifierFn = quoteIdentifierFromDialect(resolvedDialect)
}
}

private static func defaultParameterStyle(for databaseType: DatabaseType) -> ParameterStyle {
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Database/DatabaseManager+Health.swift
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ extension DatabaseManager {
// Resolve password for prompt-for-password connections
var passwordOverride = activeSessions[sessionId]?.cachedPassword
if session.connection.promptForPassword && passwordOverride == nil {
let isApiOnly = PluginManager.shared.connectionMode(for: session.connection.type) == .apiOnly
let isApiOnly = pluginManager.connectionMode(for: session.connection.type) == .apiOnly
guard let prompted = await PasswordPromptHelper.prompt(
connectionName: session.connection.name,
isAPIToken: isApiOnly,
Expand Down
6 changes: 3 additions & 3 deletions TablePro/Core/Database/DatabaseManager+SSH.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ extension DatabaseManager {
keyPassphrase = SSHProfileStorage.shared.loadKeyPassphrase(for: profileId)
totpSecret = SSHProfileStorage.shared.loadTOTPSecret(for: profileId)
case .inline:
storedSshPassword = ConnectionStorage.shared.loadSSHPassword(for: connection.id)
keyPassphrase = ConnectionStorage.shared.loadKeyPassphrase(for: connection.id)
totpSecret = ConnectionStorage.shared.loadTOTPSecret(for: connection.id)
storedSshPassword = connectionStorage.loadSSHPassword(for: connection.id)
keyPassphrase = connectionStorage.loadKeyPassphrase(for: connection.id)
totpSecret = connectionStorage.loadTOTPSecret(for: connection.id)
}

let sshPassword = sshPasswordOverride ?? storedSshPassword
Expand Down
18 changes: 9 additions & 9 deletions TablePro/Core/Database/DatabaseManager+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ extension DatabaseManager {
if let cached = activeSessions[connection.id]?.cachedPassword {
passwordOverride = cached
} else {
let isApiOnly = PluginManager.shared.connectionMode(for: connection.type) == .apiOnly
let isApiOnly = pluginManager.connectionMode(for: connection.type) == .apiOnly
guard let prompted = await PasswordPromptHelper.prompt(
connectionName: connection.name,
isAPIToken: isApiOnly,
Expand Down Expand Up @@ -150,7 +150,7 @@ extension DatabaseManager {
}

// Save as last connection for "Reopen Last Session" feature
AppSettingsStorage.shared.saveLastConnectionId(connection.id)
appSettingsStorage.saveLastConnectionId(connection.id)

// Post notification for reliable delivery
NotificationCenter.default.post(name: .databaseDidConnect, object: nil)
Expand Down Expand Up @@ -206,7 +206,7 @@ extension DatabaseManager {
case .selectDatabaseFromLastSession:
if resolvedConnection.database.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let adapter = driver as? PluginDriverAdapter,
let savedDb = AppSettingsStorage.shared.loadLastDatabase(for: connection.id) {
let savedDb = appSettingsStorage.loadLastDatabase(for: connection.id) {
do {
try await adapter.switchDatabase(to: savedDb)
activeSessions[connection.id]?.currentDatabase = savedDb
Expand Down Expand Up @@ -237,7 +237,7 @@ extension DatabaseManager {
}
case .selectSchemaFromLastSession:
if let schemaDriver = driver as? SchemaSwitchable,
let savedSchema = AppSettingsStorage.shared.loadLastSchema(for: connection.id),
let savedSchema = appSettingsStorage.loadLastSchema(for: connection.id),
savedSchema != schemaDriver.currentSchema {
do {
try await schemaDriver.switchSchema(to: savedSchema)
Expand Down Expand Up @@ -267,15 +267,15 @@ extension DatabaseManager {
session.currentDatabase = database
session.currentSchema = nil
}
AppSettingsStorage.shared.saveLastSchema(nil, for: connectionId)
appSettingsStorage.saveLastSchema(nil, for: connectionId)
await reconnectSession(connectionId)
} else if pm?.capabilities.supportsSchemaSwitching == true,
let schemaDriver = driver as? SchemaSwitchable {
try await schemaDriver.switchSchema(to: database)
updateSession(connectionId) { session in
session.currentSchema = database
}
AppSettingsStorage.shared.saveLastSchema(database, for: connectionId)
appSettingsStorage.saveLastSchema(database, for: connectionId)
return
} else if let adapter = driver as? PluginDriverAdapter {
try await adapter.switchDatabase(to: database)
Expand All @@ -288,7 +288,7 @@ extension DatabaseManager {
}
}

AppSettingsStorage.shared.saveLastDatabase(database, for: connectionId)
appSettingsStorage.saveLastDatabase(database, for: connectionId)
}

func switchSchema(to schema: String, for connectionId: UUID) async throws {
Expand All @@ -301,7 +301,7 @@ extension DatabaseManager {
updateSession(connectionId) { session in
session.currentSchema = schema
}
AppSettingsStorage.shared.saveLastSchema(schema, for: connectionId)
appSettingsStorage.saveLastSchema(schema, for: connectionId)
}

/// Switch to an existing session
Expand Down Expand Up @@ -367,7 +367,7 @@ extension DatabaseManager {
} else {
// No more sessions - clear current session and last connection ID
currentSessionId = nil
AppSettingsStorage.shared.saveLastConnectionId(nil)
appSettingsStorage.saveLastConnectionId(nil)
}
}
lifecycleLogger.info(
Expand Down
18 changes: 15 additions & 3 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ final class DatabaseManager {
static let shared = DatabaseManager()
internal static let logger = Logger(subsystem: "com.TablePro", category: "DatabaseManager")

@ObservationIgnored internal let connectionStorage: ConnectionStorage
@ObservationIgnored internal let appSettingsStorage: AppSettingsStorage
@ObservationIgnored internal let pluginManager: PluginManager

/// All active connection sessions
internal(set) var activeSessions: [UUID: ConnectionSession] = [:] {
didSet {
Expand Down Expand Up @@ -82,12 +86,20 @@ final class DatabaseManager {
currentSession?.status ?? .disconnected
}

internal init() {}
internal init(
connectionStorage: ConnectionStorage = .shared,
appSettingsStorage: AppSettingsStorage = .shared,
pluginManager: PluginManager = .shared
) {
self.connectionStorage = connectionStorage
self.appSettingsStorage = appSettingsStorage
self.pluginManager = pluginManager
}

private func persistOpenConnectionIds() {
let connections = ConnectionStorage.shared.loadConnections()
let connections = connectionStorage.loadConnections()
let activeKeys = Set(activeSessions.keys)
let ids = connections.filter { activeKeys.contains($0.id) }.map(\.id)
AppSettingsStorage.shared.saveLastOpenConnectionIds(ids)
appSettingsStorage.saveLastOpenConnectionIds(ids)
}
}
40 changes: 24 additions & 16 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ final class PluginManager {
private static let disabledPluginsKey = "com.TablePro.disabledPlugins"
private static let legacyDisabledPluginsKey = "disabledPlugins"

@ObservationIgnored private let defaults: UserDefaults
@ObservationIgnored private let builtInPluginsURL: URL?
@ObservationIgnored internal let userPluginsDir: URL

internal(set) var plugins: [PluginEntry] = []

internal(set) var isInstalling = false
Expand Down Expand Up @@ -57,10 +61,8 @@ final class PluginManager {

private static let needsRestartKey = "com.TablePro.needsRestart"

var needsRestartStorage: Bool = UserDefaults.standard.bool(
forKey: needsRestartKey
) {
didSet { UserDefaults.standard.set(needsRestartStorage, forKey: Self.needsRestartKey) }
var needsRestartStorage: Bool {
didSet { defaults.set(needsRestartStorage, forKey: Self.needsRestartKey) }
}

var needsRestart: Bool { needsRestartStorage }
Expand All @@ -73,16 +75,9 @@ final class PluginManager {

internal(set) var pluginInstances: [String: any TableProPlugin] = [:]

private var builtInPluginsDir: URL? { Bundle.main.builtInPlugInsURL }

var userPluginsDir: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("TablePro/Plugins", isDirectory: true)
}

var disabledPluginIds: Set<String> {
get { Set(UserDefaults.standard.stringArray(forKey: Self.disabledPluginsKey) ?? []) }
set { UserDefaults.standard.set(Array(newValue), forKey: Self.disabledPluginsKey) }
get { Set(defaults.stringArray(forKey: Self.disabledPluginsKey) ?? []) }
set { defaults.set(Array(newValue), forKey: Self.disabledPluginsKey) }
}

static let logger = Logger(subsystem: "com.TablePro", category: "PluginManager")
Expand All @@ -91,7 +86,21 @@ final class PluginManager {

var queryBuildingDriverCache: [String: (any PluginDatabaseDriver)?] = [:]

private init() {}
init(
userDefaults: UserDefaults = .standard,
builtInPluginsURL: URL? = Bundle.main.builtInPlugInsURL,
userPluginsDir: URL = PluginManager.defaultUserPluginsDir()
) {
self.defaults = userDefaults
self.builtInPluginsURL = builtInPluginsURL
self.userPluginsDir = userPluginsDir
self.needsRestartStorage = userDefaults.bool(forKey: Self.needsRestartKey)
}

nonisolated static func defaultUserPluginsDir() -> URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("TablePro/Plugins", isDirectory: true)
}

// MARK: - Registry Metadata

Expand Down Expand Up @@ -134,7 +143,6 @@ final class PluginManager {
}

private func migrateDisabledPluginsKey() {
let defaults = UserDefaults.standard
if let legacy = defaults.stringArray(forKey: Self.legacyDisabledPluginsKey) {
if defaults.stringArray(forKey: Self.disabledPluginsKey) == nil {
defaults.set(legacy, forKey: Self.disabledPluginsKey)
Expand Down Expand Up @@ -317,7 +325,7 @@ final class PluginManager {
}
}

if let builtInDir = builtInPluginsDir {
if let builtInDir = builtInPluginsURL {
discoverPlugins(from: builtInDir, source: .builtIn)
removeUserInstalledDuplicates(builtInDir: builtInDir)
}
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Core/SSH/LibSSH2TunnelFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -508,9 +508,9 @@ internal enum LibSSH2TunnelFactory {
// Resolve agent socket: UI config > SSH config IdentityAgent > system default
let socketPath: String?
if !config.agentSocketPath.isEmpty {
socketPath = config.agentSocketPath
socketPath = SSHPathUtilities.expandTilde(config.agentSocketPath)
} else if let agentPath = configEntry?.identityAgent, !agentPath.isEmpty {
socketPath = agentPath
socketPath = SSHPathUtilities.expandTilde(agentPath)
} else {
socketPath = nil
}
Expand Down
31 changes: 19 additions & 12 deletions TablePro/Core/Services/Infrastructure/SessionStateFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
//

import Foundation
import os

private let sessionStateLogger = Logger(subsystem: "com.TablePro", category: "SessionStateFactory")

@MainActor
enum SessionStateFactory {
Expand Down Expand Up @@ -75,18 +78,22 @@ enum SessionStateFactory {
case .table:
toolbarSt.isTableTab = true
if let tableName = payload.tableName {
if payload.isPreview {
tabMgr.addPreviewTableTab(
tableName: tableName,
databaseType: connection.type,
databaseName: payload.databaseName ?? connection.database
)
} else {
tabMgr.addTableTab(
tableName: tableName,
databaseType: connection.type,
databaseName: payload.databaseName ?? connection.database
)
do {
if payload.isPreview {
try tabMgr.addPreviewTableTab(
tableName: tableName,
databaseType: connection.type,
databaseName: payload.databaseName ?? connection.database
)
} else {
try tabMgr.addTableTab(
tableName: tableName,
databaseType: connection.type,
databaseName: payload.databaseName ?? connection.database
)
}
} catch {
sessionStateLogger.error("create tab for table failed: \(error.localizedDescription, privacy: .public)")
}
if let index = tabMgr.selectedTabIndex {
tabMgr.tabs[index].tableContext.isView = payload.isView
Expand Down
6 changes: 4 additions & 2 deletions TablePro/Core/Storage/AppSettingsStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ final class AppSettingsStorage {
static let shared = AppSettingsStorage()
private static let logger = Logger(subsystem: "com.TablePro", category: "AppSettingsStorage")

private let defaults = UserDefaults.standard
private let defaults: UserDefaults
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()

Expand All @@ -37,7 +37,9 @@ final class AppSettingsStorage {
static let hasCompletedOnboarding = "com.TablePro.settings.hasCompletedOnboarding"
}

private init() {}
init(userDefaults: UserDefaults = .standard) {
self.defaults = userDefaults
}

// MARK: - General Settings

Expand Down
Loading
Loading