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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- iCloud sync mixing up two favorite tables whose names contain a vertical bar.
- iCloud sync sending both a save and a deletion for an item unstarred and starred again, or renamed back, before it ran.
- Favorite table starred again while its removal was syncing to iCloud disappearing when the sync finished.
- Table favorites, saved queries and column layouts of **Local only** connections syncing to iCloud.
- Database favorites and column layouts removed on another Mac staying on this one.

## [0.76.1] - 2026-09-29

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import CloudKit
import Foundation
import os

public struct Tombstone: Codable, Sendable {
public struct Tombstone: Codable, Equatable, Sendable {
public let id: String
public let deletedAt: Date
public let owner: UUID?

public init(id: String, deletedAt: Date = Date()) {
public init(id: String, deletedAt: Date = Date(), owner: UUID? = nil) {
self.id = id
self.deletedAt = deletedAt
self.owner = owner
}
}

Expand Down Expand Up @@ -108,11 +110,13 @@ public final class SyncMetadataStorage: @unchecked Sendable {
addTombstones([id], type: type)
}

public func addTombstones(_ ids: [String], type: SyncRecordType) {
guard !ids.isEmpty else { return }
var current = tombstones(for: type)
current.append(contentsOf: ids.map { Tombstone(id: $0) })
saveTombstones(current, for: type)
public func addTombstones(_ ids: [String], type: SyncRecordType, owner: UUID? = nil) {
addTombstones(ids.map { Tombstone(id: $0, owner: owner) }, type: type)
}

public func addTombstones(_ added: [Tombstone], type: SyncRecordType) {
guard !added.isEmpty else { return }
saveTombstones(tombstones(for: type) + added, for: type)
}

public func removeTombstone(_ id: String, type: SyncRecordType) {
Expand All @@ -125,17 +129,50 @@ public final class SyncMetadataStorage: @unchecked Sendable {
userDefaults.removeObject(forKey: tombstoneKey(type))
}

public func pruneTombstones(olderThan days: Int) {
public func pruneTombstones(
olderThan days: Int,
where isPushable: (SyncRecordType, Tombstone) -> Bool
) {
let cutoff = Calendar.current.date(byAdding: .day, value: -days, to: Date()) ?? Date()
removeTombstones { type, tombstone in
tombstone.deletedAt < cutoff && isPushable(type, tombstone)
}
}

public func removeTombstones(where shouldRemove: (SyncRecordType, Tombstone) -> Bool) {
for type in SyncRecordType.allCases {
var current = tombstones(for: type)
let before = current.count
current.removeAll { $0.deletedAt < cutoff }
current.removeAll { shouldRemove(type, $0) }
guard current.count != before else { continue }
saveTombstones(current, for: type)
}
}

// MARK: - Owners Kept Off Sync

public func ownersKeptOffSync() -> Set<UUID> {
Set((userDefaults.stringArray(forKey: key("ownersKeptOffSync")) ?? []).compactMap(UUID.init(uuidString:)))
}

public func keepOffSync(owners: Set<UUID>) {
guard !owners.isEmpty else { return }
saveOwnersKeptOffSync(ownersKeptOffSync().union(owners))
}

public func releaseOwnersKeptOffSync(_ owners: Set<UUID>) {
guard !owners.isEmpty else { return }
saveOwnersKeptOffSync(ownersKeptOffSync().subtracting(owners))
}

private func saveOwnersKeptOffSync(_ owners: Set<UUID>) {
guard !owners.isEmpty else {
userDefaults.removeObject(forKey: key("ownersKeptOffSync"))
return
}
userDefaults.set(owners.map(\.uuidString).sorted(), forKey: key("ownersKeptOffSync"))
}

// MARK: - Last Sync Date

public var lastSyncDate: Date? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,82 @@ struct SyncMetadataStorageTests {
defaults.set(data, forKey: "com.TablePro.sync.tombstones.\(SyncRecordType.connection.rawValue)")

let storage = SyncMetadataStorage(userDefaults: defaults)
storage.pruneTombstones(olderThan: 30)
storage.pruneTombstones(olderThan: 30) { _, _ in true }

#expect(storage.tombstones(for: .connection).map(\.id) == ["fresh"])
}

@Test("Pruning keeps an old tombstone that cannot be pushed yet")
func pruningKeepsUnpushableTombstones() throws {
let defaults = UserDefaults(suiteName: "com.TablePro.tests.\(UUID().uuidString)") ?? .standard
let heldOwner = UUID()
let fortyDaysAgo = Date(timeIntervalSinceNow: -60 * 60 * 24 * 40)
let held = Tombstone(id: "held", deletedAt: fortyDaysAgo, owner: heldOwner)
let released = Tombstone(id: "released", deletedAt: fortyDaysAgo, owner: UUID())
let data = try JSONEncoder().encode([held, released])
defaults.set(data, forKey: "com.TablePro.sync.tombstones.\(SyncRecordType.tableFavorite.rawValue)")

let storage = SyncMetadataStorage(userDefaults: defaults)
storage.pruneTombstones(olderThan: 30) { _, tombstone in tombstone.owner != heldOwner }

#expect(storage.tombstones(for: .tableFavorite).map(\.id) == ["held"])
}

@Test("A tombstone keeps the owner it was recorded with")
func tombstoneOwnerRoundTrips() {
let storage = makeStorage()
let owner = UUID()
storage.addTombstones(["a", "b"], type: .tableFavorite, owner: owner)
storage.addTombstone("c", type: .tableFavorite)

#expect(storage.tombstones(for: .tableFavorite).map(\.owner) == [owner, owner, nil])
}

@Test("A tombstone written before owners existed still decodes, with no owner")
func legacyTombstoneDecodesWithoutOwner() throws {
let defaults = UserDefaults(suiteName: "com.TablePro.tests.\(UUID().uuidString)") ?? .standard
let legacy = Data(#"[{"id":"a","deletedAt":780000000}]"#.utf8)
defaults.set(legacy, forKey: "com.TablePro.sync.tombstones.\(SyncRecordType.favoriteDatabase.rawValue)")

let tombstones = SyncMetadataStorage(userDefaults: defaults).tombstones(for: .favoriteDatabase)

#expect(tombstones.map(\.id) == ["a"])
#expect(tombstones.map(\.owner) == [nil])
}

@Test("Owners kept off sync survive a new storage instance and can be released one by one")
func ownersKeptOffSyncPersist() {
let defaults = UserDefaults(suiteName: "com.TablePro.tests.\(UUID().uuidString)") ?? .standard
let first = UUID()
let second = UUID()
SyncMetadataStorage(userDefaults: defaults).keepOffSync(owners: [first, second])

let storage = SyncMetadataStorage(userDefaults: defaults)
#expect(storage.ownersKeptOffSync() == [first, second])

storage.releaseOwnersKeptOffSync([first])
#expect(storage.ownersKeptOffSync() == [second])
storage.clearAll()
#expect(storage.ownersKeptOffSync() == [second])
}

@Test("Removing tombstones by a predicate reaches every type and leaves the rest")
func removingOwnedTombstonesKeepsTheRest() {
let storage = makeStorage()
let removed = UUID()
let kept = UUID()
storage.addTombstones(["a"], type: .tableFavorite, owner: removed)
storage.addTombstones(["b"], type: .favorite, owner: removed)
storage.addTombstones(["c"], type: .tableFavorite, owner: kept)
storage.addTombstone("d", type: .tag)

storage.removeTombstones { _, tombstone in tombstone.owner == removed }

#expect(storage.tombstones(for: .tableFavorite).map(\.id) == ["c"])
#expect(storage.tombstones(for: .favorite).isEmpty)
#expect(storage.tombstones(for: .tag).map(\.id) == ["d"])
}

@Test("The last sync date round-trips")
func lastSyncDateRoundTrips() {
let storage = makeStorage()
Expand Down
102 changes: 67 additions & 35 deletions TablePro/Core/Storage/ColumnLayoutPersister.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting, TableScopedSettin
var hiddenColumns: [String]?
}

static let syncCategoryPrefix = "columnLayout."
nonisolated static let syncCategoryPrefix = "columnLayout."

private let storageDirectory: URL
private let defaults: UserDefaults
Expand Down Expand Up @@ -141,7 +141,7 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting, TableScopedSettin
cache[oldScope.connectionId] = entries
writeEntries(entries, for: oldScope.connectionId)
syncTracker.markDirty(.settings, id: Self.syncCategory(for: newKey))
syncTracker.markDeleted(.settings, id: Self.syncCategory(for: oldKey))
syncTracker.markDeleted(.settings, ids: [Self.syncCategory(for: oldKey)], owner: oldScope.connectionId)
}

/// Moves every table's saved layout from one container to another. Same prefix rewrite as the
Expand Down Expand Up @@ -173,7 +173,7 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting, TableScopedSettin
writeEntries(entries, for: connectionId)
for key in moving {
syncTracker.markDirty(.settings, id: Self.syncCategory(for: newPrefix + key.dropFirst(oldPrefix.count)))
syncTracker.markDeleted(.settings, id: Self.syncCategory(for: key))
syncTracker.markDeleted(.settings, ids: [Self.syncCategory(for: key)], owner: connectionId)
}
}

Expand All @@ -199,30 +199,24 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting, TableScopedSettin
entries.removeValue(forKey: key)
}

if entries.isEmpty {
cache[connectionId] = [:]
removeFile(for: connectionId)
} else {
cache[connectionId] = entries
writeEntries(entries, for: connectionId)
}
syncTracker.markDeleted(.settings, ids: dropping.map(Self.syncCategory(for:)))
guard store(entries, for: connectionId) else { return }
syncTracker.markDeleted(.settings, ids: dropping.map(Self.syncCategory(for:)), owner: connectionId)
}

func purgeConnections(_ connectionIds: Set<UUID>, leavesTombstones: Bool) {
var categories: [String] = []
var categoriesByConnection: [UUID: Set<String>] = [:]
for connectionId in connectionIds {
categories += loadEntries(for: connectionId).keys.map(Self.syncCategory(for:))
categoriesByConnection[connectionId] = Set(loadEntries(for: connectionId).keys.map(Self.syncCategory(for:)))
cache[connectionId] = [:]
removeFile(for: connectionId)
}
/// The dirty marks go either way. A tombstone from a remote delete would push the sender's
/// own deletion back at it, but leaving the ids dirty means the next push looks for entries
/// that are gone and never drains them.
if leavesTombstones {
syncTracker.markDeleted(.settings, ids: categories)
syncTracker.markDeleted(.settings, idsByOwner: categoriesByConnection)
} else {
syncTracker.discardDirty(.settings, ids: categories)
syncTracker.discardDirty(.settings, ids: categoriesByConnection.values.flatMap { $0 })
}
}

Expand All @@ -232,14 +226,8 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting, TableScopedSettin
var entries = loadEntries(for: key.connectionId)
guard entries.removeValue(forKey: key.storageKey) != nil else { return }

if entries.isEmpty {
cache[key.connectionId] = [:]
removeFile(for: key.connectionId)
} else {
cache[key.connectionId] = entries
writeEntries(entries, for: key.connectionId)
}
syncTracker.markDeleted(.settings, id: Self.syncCategory(for: key.storageKey))
guard store(entries, for: key.connectionId) else { return }
syncTracker.markDeleted(.settings, ids: [Self.syncCategory(for: key.storageKey)], owner: key.connectionId)
}

func clearGeometry(for key: ColumnLayoutTableKey) {
Expand All @@ -256,21 +244,48 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting, TableScopedSettin
syncTracker.markDirty(.settings, id: Self.syncCategory(for: key.storageKey))
} else {
entries.removeValue(forKey: key.storageKey)
if entries.isEmpty {
cache[key.connectionId] = [:]
removeFile(for: key.connectionId)
} else {
cache[key.connectionId] = entries
writeEntries(entries, for: key.connectionId)
}
syncTracker.markDeleted(.settings, id: Self.syncCategory(for: key.storageKey))
guard store(entries, for: key.connectionId) else { return }
syncTracker.markDeleted(.settings, ids: [Self.syncCategory(for: key.storageKey)], owner: key.connectionId)
}
}

static func syncCategory(for storageKey: String) -> String {
syncCategoryPrefix + storageKey
}

nonisolated static func connectionId(ofSyncCategory category: String) -> UUID? {
guard category.hasPrefix(syncCategoryPrefix) else { return nil }
return TableScope(storageComponent: String(category.dropFirst(syncCategoryPrefix.count)))?.connectionId
}

func storageKeys(forSyncRecordNames recordNames: Set<String>) -> [String] {
let layoutPrefix = SyncRecordType.settings.recordNamePrefix + Self.syncCategoryPrefix
let digestPrefix = SyncRecordType.settings.recordNamePrefix + SyncRecordName.digestPrefix
let named = recordNames.filter { $0.hasPrefix(layoutPrefix) }.map { String($0.dropFirst(layoutPrefix.count)) }
let digested = recordNames.filter { $0.hasPrefix(digestPrefix) }
guard !digested.isEmpty else { return named }
return named + customizedStorageKeys().filter { storageKey in
digested.contains(SyncRecordType.settings.recordName(for: Self.syncCategory(for: storageKey)))
}
}

func removeWithoutSync(storageKeys: [String]) -> Bool {
let scoped = storageKeys.compactMap { key in TableScope(storageComponent: key).map { (key, $0.connectionId) } }
var persisted = true
for (connectionId, keys) in Dictionary(grouping: scoped, by: \.1) {
var entries = loadEntries(for: connectionId)
let removed = keys.map(\.0).filter { entries.removeValue(forKey: $0) != nil }
guard !removed.isEmpty else { continue }
guard store(entries, for: connectionId) else {
persisted = false
continue
}
removed.forEach(removeLegacyHidden(storageKey:))
syncTracker.discardDirty(.settings, ids: removed.map(Self.syncCategory(for:)))
}
return persisted
}

func rawData(forStorageKey storageKey: String) -> Data? {
guard let scope = TableScope(storageComponent: storageKey),
let entry = loadEntries(for: scope.connectionId)[storageKey] else { return nil }
Expand Down Expand Up @@ -309,7 +324,18 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting, TableScopedSettin
}

private func removeLegacyHidden(for key: ColumnLayoutTableKey) {
defaults.removeObject(forKey: Self.legacyVisibilityPrefix + key.storageKey)
removeLegacyHidden(storageKey: key.storageKey)
}

private func removeLegacyHidden(storageKey: String) {
defaults.removeObject(forKey: Self.legacyVisibilityPrefix + storageKey)
}

@discardableResult
private func store(_ entries: [String: PersistedColumnLayout], for connectionId: UUID) -> Bool {
let stored = entries.isEmpty ? removeFile(for: connectionId) : writeEntries(entries, for: connectionId)
cache[connectionId] = stored ? entries : nil
return stored
}

private func loadEntries(for connectionId: UUID) -> [String: PersistedColumnLayout] {
Expand All @@ -335,27 +361,33 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting, TableScopedSettin
}
}

private func writeEntries(_ entries: [String: PersistedColumnLayout], for connectionId: UUID) {
@discardableResult
private func writeEntries(_ entries: [String: PersistedColumnLayout], for connectionId: UUID) -> Bool {
let fileURL = fileURL(for: connectionId)
do {
let data = try encoder.encode(entries)
try data.write(to: fileURL, options: .atomic)
return true
} catch {
Self.logger.error(
"Failed to write column layouts for \(connectionId): \(error.localizedDescription)"
)
return false
}
}

private func removeFile(for connectionId: UUID) {
@discardableResult
private func removeFile(for connectionId: UUID) -> Bool {
let fileURL = fileURL(for: connectionId)
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
guard FileManager.default.fileExists(atPath: fileURL.path) else { return true }
do {
try FileManager.default.removeItem(at: fileURL)
return true
} catch {
Self.logger.error(
"Failed to remove column layout file for \(connectionId): \(error.localizedDescription)"
)
return false
}
}

Expand Down
Loading
Loading