git.iko.soy/parseable-macos
on master help sign in
Fix 11 bugs: NaN sort trap, numeric equality, filter drops rows, duplicate connections, credential loss, atomic keychain, and more
commit8aff8ef7349b6e6bdc014931c077268d55464996 authoriko <mail@iko.soy> dateSat 20 Jun 2026, 23:19 (3 months ago) parenta85b560
10 files changed+186−42
ParseableViewer/Models/JSONValue.swift+56−10 ParseableViewer/Models/SQLTokenizer.swift+11−1 ParseableViewer/Models/ServerConnection.swift+16 ParseableViewer/Services/ConnectionStore.swift+9−6 ParseableViewer/Services/KeychainService.swift+28−18 ParseableViewer/Services/ParseableClient.swift+10−3 ParseableViewer/ViewModels/QueryViewModel.swift+17−2 ParseableViewer/Views/ConnectionSheet.swift+6−1 ParseableViewer/Views/LiveTailView.swift+24−1 ParseableViewer/Views/LogTableView.swift+9
ParseableViewer/Models/JSONValue.swift+56−10
@@ -160,20 +160,22 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
160160 /// Escapes special characters for valid JSON string output.
161161 static func escapeJSONString(_ s: String) -> String {
162162 var result = ""
163− result.reserveCapacity(s.count)
164− for char in s {
165− switch char {
163+ result.unicodeScalars.reserveCapacity(s.unicodeScalars.count)
164+ for scalar in s.unicodeScalars {
165+ switch scalar {
166166 case "\"": result += "\\\""
167167 case "\\": result += "\\\\"
168168 case "\n": result += "\\n"
169169 case "\r": result += "\\r"
170170 case "\t": result += "\\t"
171171 default:
172− if char.asciiValue.map({ $0 < 0x20 }) == true {
173− let code = char.asciiValue!
174− result += String(format: "\\u%04x", code)
172+ // Any control character (U+0000–U+001F), including those that
173+ // would otherwise combine into a grapheme cluster, must be
174+ // escaped to produce valid JSON.
175+ if scalar.value < 0x20 {
176+ result += String(format: "\\u%04x", scalar.value)
175177 } else {
176− result.append(char)
178+ result.unicodeScalars.append(scalar)
177179 }
178180 }
179181 }
@@ -190,15 +192,59 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
190192 case (_, .null): return false
191193 case (.bool(let a), .bool(let b)): return !a && b
192194 case (.int(let a), .int(let b)): return a < b
193− case (.double(let a), .double(let b)): return a < b
194− case (.int(let a), .double(let b)): return Double(a) < b
195− case (.double(let a), .int(let b)): return a < Double(b)
195+ case (.double(let a), .double(let b)): return doubleLessThan(a, b)
196+ case (.int(let a), .double(let b)): return doubleLessThan(Double(a), b)
197+ case (.double(let a), .int(let b)): return doubleLessThan(a, Double(b))
196198 case (.string(let a), .string(let b)):
197199 return a.localizedStandardCompare(b) == .orderedAscending
198200 default:
199201 return lhs.displayString < rhs.displayString
200202 }
201203 }
204+
205+ /// Total ordering for doubles that keeps `sort()` from trapping on NaN
206+ /// (IEEE comparisons with NaN are always false, which violates the strict
207+ /// weak ordering `Comparable` requires). NaN is ordered after all numbers.
208+ private static func doubleLessThan(_ a: Double, _ b: Double) -> Bool {
209+ if a.isNaN { return false }
210+ if b.isNaN { return true }
211+ return a < b
212+ }
213+
214+ // MARK: - Equatable / Hashable
215+
216+ /// Equality treats numerically-equal integers and doubles as equal
217+ /// (e.g. `.int(1) == .double(1.0)`) to stay consistent with `<`, which
218+ /// compares the two numerically. Synthesized conformance would instead
219+ /// distinguish them by enum case, violating the `Comparable` contract.
220+ static func == (lhs: JSONValue, rhs: JSONValue) -> Bool {
221+ switch (lhs, rhs) {
222+ case (.null, .null): return true
223+ case (.bool(let a), .bool(let b)): return a == b
224+ case (.int(let a), .int(let b)): return a == b
225+ case (.double(let a), .double(let b)): return a == b
226+ case (.int(let a), .double(let b)): return Double(a) == b
227+ case (.double(let a), .int(let b)): return a == Double(b)
228+ case (.string(let a), .string(let b)): return a == b
229+ case (.array(let a), .array(let b)): return a == b
230+ case (.object(let a), .object(let b)): return a == b
231+ default: return false
232+ }
233+ }
234+
235+ func hash(into hasher: inout Hasher) {
236+ switch self {
237+ case .null: hasher.combine(0)
238+ case .bool(let v): hasher.combine(v)
239+ // Hash integers via their Double value so `.int(1)` and `.double(1.0)`
240+ // — which are `==` — produce the same hash.
241+ case .int(let v): hasher.combine(Double(v))
242+ case .double(let v): hasher.combine(v)
243+ case .string(let v): hasher.combine(v)
244+ case .array(let v): hasher.combine(v)
245+ case .object(let v): hasher.combine(v)
246+ }
247+ }
202248}
203249
204250typealias LogRecord = [String: JSONValue]
ParseableViewer/Models/SQLTokenizer.swift+11−1
@@ -256,7 +256,17 @@ struct SQLTokenizer: Sendable {
256256 }
257257
258258 private static func consumeNumber(_ i: inout String.Index, in sql: String) {
259− advance(&i, in: sql, while: { $0.isNumber || $0 == "." })
259+ // Digits with at most one decimal point, so malformed input like
260+ // `1.2.3` doesn't get swallowed into a single number token.
261+ var seenDot = false
262+ advance(&i, in: sql, while: { c in
263+ if c == "." {
264+ if seenDot { return false }
265+ seenDot = true
266+ return true
267+ }
268+ return c.isNumber
269+ })
260270 // Exponent
261271 if i < sql.endIndex, sql[i] == "e" || sql[i] == "E" {
262272 sql.formIndex(after: &i)
ParseableViewer/Models/ServerConnection.swift+16
@@ -37,6 +37,22 @@ struct ServerConnection: Identifiable, Codable, Hashable, Sendable {
3737 try container.encode(username, forKey: .username)
3838 }
3939
40+ // `password` is deliberately excluded from equality and hashing: it isn't
41+ // serialized (see CodingKeys) and is repopulated from the Keychain on
42+ // decode, so including it would make a decoded connection compare unequal
43+ // to the in-memory original whenever the Keychain read returns "" — e.g. in
44+ // Set membership or "has this connection changed?" checks.
45+ static func == (lhs: ServerConnection, rhs: ServerConnection) -> Bool {
46+ lhs.id == rhs.id
47+ && lhs.name == rhs.name
48+ && lhs.url == rhs.url
49+ && lhs.username == rhs.username
50+ }
51+
52+ func hash(into hasher: inout Hasher) {
53+ hasher.combine(id)
54+ }
55+
4056 var baseURL: URL? {
4157 var urlString = url.trimmingCharacters(in: .whitespacesAndNewlines)
4258 if urlString.isEmpty { return nil }
ParseableViewer/Services/ConnectionStore.swift+9−6
@@ -12,12 +12,15 @@ final class ConnectionStore {
1212 }
1313
1414 static func saveConnections(_ connections: [ServerConnection]) {
15− for connection in connections {
16− if !connection.password.isEmpty {
17− KeychainService.savePassword(connection.password, for: connection.id)
18− } else {
19− KeychainService.deletePassword(for: connection.id)
20− }
15+ for connection in connections where !connection.password.isEmpty {
16+ // Only upsert non-empty passwords. We must NOT delete on an empty
17+ // password here: `ServerConnection.init(from:)` decodes the password
18+ // as "" whenever the Keychain read transiently fails (locked
19+ // Keychain, errSecInteractionNotAllowed, etc.), so deleting on empty
20+ // would promote a transient read failure into permanent credential
21+ // loss on the next save. Credentials are removed only when the
22+ // connection itself is removed, via `deleteConnection`.
23+ KeychainService.savePassword(connection.password, for: connection.id)
2124 }
2225 if let data = try? JSONEncoder().encode(connections) {
2326 KeychainService.saveData(data, for: keychainConnectionsKey)
ParseableViewer/Services/KeychainService.swift+28−18
@@ -8,17 +8,8 @@ enum KeychainService {
88
99 @discardableResult
1010 static func savePassword(_ password: String, for connectionID: UUID) -> Bool {
11− let account = connectionID.uuidString
12− deletePassword(for: connectionID)
13−
1411 guard let data = password.data(using: .utf8) else { return false }
15− var query = baseQuery(for: connectionID)
16− query[kSecValueData as String] = data
17− let status = SecItemAdd(query as CFDictionary, nil)
18− if status != errSecSuccess {
19− logger.error("Failed to save password for \(account): OSStatus \(status)")
20− }
21− return status == errSecSuccess
12+ return upsert(data, query: baseQuery(for: connectionID), label: connectionID.uuidString)
2213 }
2314
2415 static func loadPassword(for connectionID: UUID) -> String? {
@@ -51,14 +42,7 @@ enum KeychainService {
5142
5243 @discardableResult
5344 static func saveData(_ data: Data, for key: String) -> Bool {
54− deleteData(for: key)
55− var query = baseQuery(for: key)
56− query[kSecValueData as String] = data
57− let status = SecItemAdd(query as CFDictionary, nil)
58− if status != errSecSuccess {
59− logger.error("Failed to save data for \(key): OSStatus \(status)")
60− }
61− return status == errSecSuccess
45+ upsert(data, query: baseQuery(for: key), label: key)
6246 }
6347
6448 static func loadData(for key: String) -> Data? {
@@ -85,6 +69,32 @@ enum KeychainService {
8569 return status == errSecSuccess || status == errSecItemNotFound
8670 }
8771
72+ // MARK: - Upsert
73+
74+ /// Atomically stores `data` for the given query: tries `SecItemAdd` and,
75+ /// if the item already exists, falls back to `SecItemUpdate`. This avoids
76+ /// the delete-then-add race that could drop a write (and silently return
77+ /// `errSecDuplicateItem`) when two saves overlap.
78+ @discardableResult
79+ private static func upsert(_ data: Data, query: [String: Any], label: String) -> Bool {
80+ var addQuery = query
81+ addQuery[kSecValueData as String] = data
82+ let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
83+ if addStatus == errSecSuccess {
84+ return true
85+ }
86+ if addStatus == errSecDuplicateItem {
87+ let attributes = [kSecValueData as String: data]
88+ let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
89+ if updateStatus != errSecSuccess {
90+ logger.error("Failed to update item for \(label): OSStatus \(updateStatus)")
91+ }
92+ return updateStatus == errSecSuccess
93+ }
94+ logger.error("Failed to save item for \(label): OSStatus \(addStatus)")
95+ return false
96+ }
97+
8898 // MARK: - Base queries
8999
90100 private static func baseQuery(for connectionID: UUID) -> [String: Any] {
ParseableViewer/Services/ParseableClient.swift+10−3
@@ -288,9 +288,16 @@ final class ParseableClient: Sendable {
288288 return []
289289 }
290290
291− // Peek at the first non-whitespace byte to choose the decoder path:
292− // '{' → wrapped response, '[' → direct array.
293− let firstByte = responseData.first(where: { $0 != 0x20 && $0 != 0x0A && $0 != 0x0D && $0 != 0x09 })
291+ // Peek at the first meaningful byte to choose the decoder path:
292+ // '{' → wrapped response, '[' → direct array. Skip a leading UTF-8 BOM
293+ // (0xEF 0xBB 0xBF) and ASCII whitespace so a BOM-prefixed wrapped
294+ // response isn't misrouted to the array decoder. (JSONDecoder itself
295+ // tolerates the BOM.)
296+ var sniff = responseData
297+ if sniff.starts(with: [0xEF, 0xBB, 0xBF]) {
298+ sniff = sniff.dropFirst(3)
299+ }
300+ let firstByte = sniff.first(where: { $0 != 0x20 && $0 != 0x0A && $0 != 0x0D && $0 != 0x09 })
294301 if firstByte == 0x7B { // '{'
295302 do {
296303 return try Self.jsonDecoder.decode(QueryResponse.self, from: responseData).records
ParseableViewer/ViewModels/QueryViewModel.swift+17−2
@@ -286,8 +286,22 @@ final class QueryViewModel {
286286 filteredResults = results
287287 } else {
288288 let text = filterText
289− filteredResults = zip(results, cachedSearchTexts).compactMap { record, searchText in
290− searchText.localizedCaseInsensitiveContains(text) ? record : nil
289+ // The search-text cache is built asynchronously, so it may be empty
290+ // or stale when the filter changes. `zip` would silently truncate to
291+ // the shorter sequence and drop matching rows, so only use the cache
292+ // when it lines up with the current results; otherwise compute the
293+ // search text inline for correctness.
294+ if cachedSearchTexts.count == results.count {
295+ filteredResults = zip(results, cachedSearchTexts).compactMap { record, searchText in
296+ searchText.localizedCaseInsensitiveContains(text) ? record : nil
297+ }
298+ } else {
299+ filteredResults = results.filter { record in
300+ record.values
301+ .map { $0.displayString }
302+ .joined(separator: " ")
303+ .localizedCaseInsensitiveContains(text)
304+ }
291305 }
292306 }
293307 }
@@ -617,6 +631,7 @@ final class QueryViewModel {
617631
618632 func clearHistory() {
619633 queryHistory = []
634+ historyIsFull = false
620635 Self.saveHistory([])
621636 }
622637
ParseableViewer/Views/ConnectionSheet.swift+6−1
@@ -14,6 +14,10 @@ struct ConnectionSheet: View {
1414 @State private var isSaving = false
1515 @State private var testResult: TestResult?
1616 @State private var urlValidationError: String?
17+ /// For a new connection, holds the connection created on the first save so a
18+ /// retry (after a failed connect) updates it in place rather than appending a
19+ /// duplicate.
20+ @State private var createdConnection: ServerConnection?
1721
1822 enum TestResult {
1923 case success(String)
@@ -160,12 +164,13 @@ struct ConnectionSheet: View {
160164 guard !isSaving else { return }
161165
162166 let conn: ServerConnection
163− if let existing = connection {
167+ if let existing = connection ?? createdConnection {
164168 conn = ServerConnection(id: existing.id, name: name, url: url, username: username, password: password)
165169 appState.updateConnection(conn)
166170 } else {
167171 conn = ServerConnection(name: name, url: url, username: username, password: password)
168172 appState.addConnection(conn)
173+ createdConnection = conn
169174 }
170175
171176 isSaving = true
ParseableViewer/Views/LiveTailView.swift+24−1
@@ -13,6 +13,9 @@ struct LiveTailView: View {
1313 @State private var columnWidths: [String: CGFloat] = [:]
1414 @State private var cachedSorted: [LiveTailViewModel.LiveTailEntry] = []
1515 @State private var severityColumnSet: Set<String> = []
16+ /// Severity per entry id, precomputed on rebuild so it isn't recomputed for
17+ /// every visible row on each scroll/render.
18+ @State private var severityByID: [UUID: SeverityLevel] = [:]
1619 @State private var liveTailSortTask: Task<Void, Never>?
1720
1821 var body: some View {
@@ -277,13 +280,32 @@ struct LiveTailView: View {
277280 }
278281 }
279282
283+ /// Computes widths for any visible columns that don't have one yet. Covers
284+ /// the case where switching to a stream with an identical column set doesn't
285+ /// fire `onChange(of: visibleColumns)`, which would otherwise leave widths
286+ /// empty (falling back to the default width) after `selectedStream` cleared
287+ /// them.
288+ private func ensureColumnWidths() {
289+ let records = viewModel.cachedFilteredRecords
290+ for col in viewModel.visibleColumns where columnWidths[col] == nil {
291+ columnWidths[col] = idealColumnWidth(for: col, records: records)
292+ }
293+ }
294+
280295 private func rebuildSortedEntries() {
296+ ensureColumnWidths()
281297 let entries = viewModel.cachedFilteredEntries
282298 let cols = viewModel.visibleColumns
283299 let sevCols = buildSeverityColumnSet(columns: cols)
300+ // Precompute severity per entry; sorting doesn't change the set.
301+ let severityMap = Dictionary(
302+ entries.map { ($0.id, extractSeverity(from: $0.record, severityColumns: sevCols)) },
303+ uniquingKeysWith: { first, _ in first }
304+ )
284305 guard let col = sortColumn else {
285306 cachedSorted = entries
286307 severityColumnSet = sevCols
308+ severityByID = severityMap
287309 return
288310 }
289311 liveTailSortTask?.cancel()
@@ -301,6 +323,7 @@ struct LiveTailView: View {
301323 guard !Task.isCancelled else { return }
302324 cachedSorted = sorted
303325 severityColumnSet = sevCols
326+ severityByID = severityMap
304327 }
305328 }
306329
@@ -316,7 +339,7 @@ struct LiveTailView: View {
316339 columnWidths: columnWidths,
317340 isSelected: selectedRecord == entry.record,
318341 isAlternate: index % 2 == 1,
319− severity: extractSeverity(from: entry.record, severityColumns: severityColumnSet),
342+ severity: severityByID[entry.id] ?? .unknown,
320343 severityColumns: severityColumnSet,
321344 wrapText: wrapText,
322345 onCellFilter: { column, value, exclude in
ParseableViewer/Views/LogTableView.swift+9
@@ -259,6 +259,15 @@ struct LogTableView: View {
259259 }
260260 debouncedRebuildSort()
261261 }
262+ .onChange(of: columns) { _, newColumns in
263+ // After a stream switch the previous sort column may not exist
264+ // in the new schema, which would silently produce a no-op sort
265+ // (every row compares as .null). Drop it so rows fall back to
266+ // their natural order.
267+ if let sortColumn, !newColumns.contains(sortColumn) {
268+ self.sortColumn = nil
269+ }
270+ }
262271 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
263272 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
264273 .onAppear {