Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Edits made while an iCloud sync was running reverted by that sync and never uploaded.
- Saved queries unavailable until relaunch after their store failed to open once.
- SQL files saved as UTF-8 whatever their encoding, and non-UTF-8 SQL files not opening from Finder or **File > Open File…**.
- Unresponsive app while comparing large query plans.
- Slow definition diff in Compare & Sync for large tables.
- Autocomplete offering another schema's tables without their schema once that schema was completed or expanded.
- Stale column and MongoDB field suggestions when a refresh ran while they were loading.
- Tables in an expanded Oracle or Snowflake schema missing from Open Quickly until the next refresh.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,15 @@ public actor CloudKitSyncEngine {
)

for plan in plans {
outcome.merge(try await push(
plan: plan,
records: publishableRecords,
deletions: publishableDeletions
))
do {
outcome.merge(try await push(
plan: plan,
records: publishableRecords,
deletions: publishableDeletions
))
} catch {
throw SyncPushInterruption.after(outcome, failingWith: error)
}
}

let saved = outcome.savedRecords.count
Expand Down Expand Up @@ -129,7 +133,11 @@ public actor CloudKitSyncEngine {

var outcome = PushOutcome()
for half in halves {
outcome.merge(try await push(plan: half, records: records, deletions: deletions))
do {
outcome.merge(try await push(plan: half, records: records, deletions: deletions))
} catch {
throw SyncPushInterruption.after(outcome, failingWith: error)
}
}
return outcome
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ public struct PushOutcome: Sendable {

public var hasFailures: Bool { !failures.isEmpty }

public var isEmpty: Bool {
savedRecords.isEmpty && deletedRecordIDs.isEmpty && failures.isEmpty
}

public var conflicts: [CKRecord.ID: SyncItemFailure] {
failures.filter(\.value.isConflict)
}
Expand Down Expand Up @@ -90,3 +94,23 @@ public struct PushOutcome: Sendable {
}
}
}

public struct SyncPushInterruption: Error, Sendable {
public let completed: PushOutcome
public let cause: any Error

public init(completed: PushOutcome, cause: any Error) {
self.completed = completed
self.cause = cause
}

public static func after(_ completed: PushOutcome, failingWith error: any Error) -> any Error {
if let interruption = error as? SyncPushInterruption {
var merged = completed
merged.merge(interruption.completed)
return SyncPushInterruption(completed: merged, cause: interruption.cause)
}
guard !completed.isEmpty else { return error }
return SyncPushInterruption(completed: completed, cause: error)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ public enum SyncError: Error, LocalizedError, Equatable, Sendable {
return syncError
}

if let interruption = error as? SyncPushInterruption {
return from(interruption.cause)
}

if let ckError = error as? CKError {
switch ckError.code {
case .networkUnavailable, .networkFailure:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,59 @@ struct PushOutcomeTests {
#expect(outcome.failures[rejected] != nil)
#expect(!outcome.didDelete(rejected))
}

@Test("A push that stopped after saving some records carries those saves with the error")
func interruptionKeepsWhatWasSaved() throws {
var completed = PushOutcome()
let saved = makeRecord("Connection_Saved")
completed.recordSave(saved)

let error = SyncPushInterruption.after(completed, failingWith: CKError(.networkFailure))

let interruption = try #require(error as? SyncPushInterruption)
#expect(interruption.completed.didSave(saved.recordID))
#expect((interruption.cause as? CKError)?.code == .networkFailure)
}

@Test("A push that stopped before saving anything throws its own error unchanged")
func interruptionWithoutProgressIsTheRawError() {
let error = SyncPushInterruption.after(PushOutcome(), failingWith: CKError(.networkFailure))

#expect(!(error is SyncPushInterruption))
#expect((error as? CKError)?.code == .networkFailure)
}

@Test("An interruption from a later batch keeps the saves of the batches before it")
func nestedInterruptionsMerge() throws {
var earlier = PushOutcome()
let first = makeRecord("Connection_First")
earlier.recordSave(first)
var later = PushOutcome()
let second = makeRecord("Connection_Second")
later.recordSave(second)
let inner = SyncPushInterruption(completed: later, cause: CKError(.networkFailure))

let error = SyncPushInterruption.after(earlier, failingWith: inner)

let interruption = try #require(error as? SyncPushInterruption)
#expect(interruption.completed.didSave(first.recordID))
#expect(interruption.completed.didSave(second.recordID))
}

@Test("An interrupted push reports the error that stopped it")
func interruptionMapsToItsCause() {
let interruption = SyncPushInterruption(completed: PushOutcome(), cause: CKError(.networkFailure))

#expect(SyncError.from(interruption) == .networkUnavailable)
}

@Test("An outcome with nothing saved, deleted or rejected is empty")
func emptyOutcome() {
var outcome = PushOutcome()
#expect(outcome.isEmpty)

outcome.recordDeletion(recordID("Connection_Gone"))

#expect(!outcome.isEmpty)
}
}
30 changes: 30 additions & 0 deletions TablePro/Core/Diff/StructureDefinitionDiffPresentation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// StructureDefinitionDiffPresentation.swift
// TablePro
//

import Foundation

internal struct StructureDefinitionDiffInput: Equatable, Sendable {
let sourceLines: [String]
let targetLines: [String]
}

internal struct StructureDefinitionDiffPresentation: Equatable, Sendable {
let input: StructureDefinitionDiffInput
let pairs: [DiffPair]

init(input: StructureDefinitionDiffInput) {
self.input = input
pairs = DiffComputer.computeSplit(before: input.targetLines, after: input.sourceLines)
}

@concurrent
static func load(_ input: StructureDefinitionDiffInput) async -> StructureDefinitionDiffPresentation {
StructureDefinitionDiffPresentation(input: input)
}

func isCurrent(for input: StructureDefinitionDiffInput) -> Bool {
self.input == input
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ internal enum EditorTabOpener {
databaseName: payload.databaseName ?? browseDatabaseName,
sourceFileURL: payload.sourceFileURL,
sourceFileStamp: payload.sourceFileStamp,
sourceFileEncoding: payload.sourceFileEncoding,
claimFocus: true
)
case .createTable:
Expand Down
17 changes: 6 additions & 11 deletions TablePro/Core/Services/Infrastructure/SQLFileService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,21 @@ enum SQLFileService {
return types.isEmpty ? [.plainText] : Array(types)
}

/// Reads a SQL file from disk.
static func readFile(url: URL) async throws -> String {
static func writeFile(content: String, to url: URL, encoding: FileTextEncoding) async throws {
try await Task.detached {
try String(contentsOf: url, encoding: .utf8)
try FileTextWriter.write(content, to: url, as: encoding)
}.value
}

/// Writes content to a SQL file atomically.
static func writeFile(content: String, to url: URL) async throws {
try await Task.detached {
guard let data = content.data(using: .utf8) else {
throw CocoaError(.fileWriteInapplicableStringEncoding)
}
try data.write(to: url, options: .atomic)
static func encodingOnDisk(of url: URL) async -> FileTextEncoding? {
await Task.detached {
FileTextLoader.load(url)?.textEncoding
}.value
}

static func writeData(_ data: Data, to url: URL) async throws {
try await Task.detached {
try data.write(to: url, options: .atomic)
try FileTextWriter.replaceContents(of: url, with: data, attribute: TextEncodingAttribute.read(from: url))
}.value
}

Expand Down
37 changes: 21 additions & 16 deletions TablePro/Core/Services/Infrastructure/TabRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -488,29 +488,34 @@ internal final class TabRouter {
}

if let session = DatabaseManager.shared.lastActiveSession {
let read = await Task.detached(priority: .userInitiated) { () -> (content: String, stamp: FileStamp?)? in
let stamp = FileStamp.read(url)
guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil }
return (content, stamp)
}.value
guard let read else {
Self.logger.error("Failed to read SQL file: \(url.lastPathComponent, privacy: .private(mask: .hash))")
return
}
let payload = EditorTabPayload(
connectionId: session.connection.id,
tabType: .query,
initialQuery: read.content,
sourceFileURL: url,
sourceFileStamp: read.stamp
)
let payload = try await Self.sqlFileTabPayload(for: url, connectionId: session.connection.id)
WindowManager.shared.openTab(payload: payload)
AppActivationPolicyController.shared.activate(ignoringOtherApps: true)
} else {
WelcomeRouter.shared.enqueueSQLFile(url)
}
}

internal static func sqlFileTabPayload(for url: URL, connectionId: UUID) async throws -> EditorTabPayload {
let read: FileTextLoader.LoadedText
do {
read = try await Task.detached(priority: .userInitiated) {
try FileTextLoader.read(url)
}.value
} catch {
logger.error("Failed to read SQL file: \(url.lastPathComponent, privacy: .private(mask: .hash))")
throw error
}
return EditorTabPayload(
connectionId: connectionId,
tabType: .query,
initialQuery: read.content,
sourceFileURL: url,
sourceFileStamp: read.stamp,
sourceFileEncoding: read.textEncoding
)
}

// MARK: - Helpers

internal func bringConnectionWindowToFront(_ connectionId: UUID) {
Expand Down
12 changes: 5 additions & 7 deletions TablePro/Core/Services/SQL/LinkedSQLFavoriteWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ internal enum LinkedSQLFavoriteWriter {

enum WriteError: Error {
case readFailed
case encodingMismatch(String.Encoding)
case encodingMismatch(FileTextEncoding)
case writeFailed
}

Expand All @@ -33,12 +33,10 @@ internal enum LinkedSQLFavoriteWriter {

let newContent = rewrite(loaded.content, with: metadata)
do {
try newContent.write(to: url, atomically: true, encoding: loaded.encoding)
} catch let error as NSError where
error.domain == NSCocoaErrorDomain &&
error.code == NSFileWriteInapplicableStringEncodingError {
Self.logger.error("Encoding \(loaded.encoding.rawValue) cannot represent edited content at \(url.path, privacy: .private(mask: .hash))")
throw WriteError.encodingMismatch(loaded.encoding)
try FileTextWriter.write(newContent, to: url, as: loaded.textEncoding)
} catch FileTextWriter.WriteError.unrepresentable(let encoding) {
Self.logger.error("Encoding \(encoding.encoding.rawValue) cannot represent edited content at \(url.path, privacy: .private(mask: .hash))")
throw WriteError.encodingMismatch(encoding)
} catch {
Self.logger.error("Failed to write metadata to \(url.path, privacy: .private(mask: .hash)): \(error.publicLogShape, privacy: .public)")
throw WriteError.writeFailed
Expand Down
8 changes: 7 additions & 1 deletion TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ final class ConnectionStorage {
/// In-memory cache to avoid re-decoding JSON from file on every access
private var cachedConnections: [DatabaseConnection]?

private(set) var lastLoadFailed = false

/// Whether the file on disk is the one TablePro last wrote. False once it has been edited by
/// something else, which is the signal to refuse to run a connection's password source.
var storeIsTrusted: Bool { file.isTrusted }
Expand Down Expand Up @@ -90,7 +92,11 @@ final class ConnectionStorage {
func loadConnections() -> [DatabaseConnection] {
if let cached = cachedConnections { return cached }

guard let storedConnections = file.load() else { return [] }
guard let storedConnections = file.load() else {
lastLoadFailed = true
return []
}
lastLoadFailed = false

let connections = storedConnections.map { stored in
stored.toConnection()
Expand Down
3 changes: 2 additions & 1 deletion TablePro/Core/Storage/CredentialProfileStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ final class CredentialProfileStorage {

@discardableResult
func saveProfiles(_ profiles: [CredentialProfile]) -> Bool {
let previous = loadProfiles()
guard saveProfilesWithoutSync(profiles) else { return false }
syncTracker.markDirty(.credentialProfile, ids: profiles.map { $0.id.uuidString })
syncTracker.markDirty(.credentialProfile, ids: SyncRecordChanges.changedIds(from: previous, to: profiles))
return true
}

Expand Down
5 changes: 4 additions & 1 deletion TablePro/Core/Storage/FavoriteDatabasesStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ internal final class FavoriteDatabasesStorage {
persist(favorites)

guard !skipSync else {
syncTracker.discardDirty(.favoriteDatabase, ids: removed.map(Self.syncId(for:)))
postChangeNotification()
return
}
Expand Down Expand Up @@ -169,7 +170,9 @@ internal final class FavoriteDatabasesStorage {
}
postChangeNotification()
case .removed(let entry):
if !skipSync {
if skipSync {
syncTracker.discardDirty(.favoriteDatabase, ids: [Self.syncId(for: entry)])
} else {
syncTracker.markDeleted(.favoriteDatabase, id: Self.syncId(for: entry))
}
postChangeNotification()
Expand Down
Loading
Loading