git.iko.soy/parseable-macos
on master help sign in
Fix 6 bugs: saved-filter onChange race, JSON export column leak, live-tail poll late-write and clear repopulation, formatter locale, and stale stream selection
commita0cf99e1f3f44d93854467e2a4244b0ca46e16b4 authoriko <mail@iko.soy> dateSun 21 Jun 2026, 01:25 (3 months ago) parenta36e08c
5 files changed+96−17
ParseableViewer/App/AppState.swift+6 ParseableViewer/ViewModels/LiveTailViewModel.swift+29−1 ParseableViewer/ViewModels/QueryViewModel.swift+12−1 ParseableViewer/Views/QueryView.swift+36−15 ParseableViewerTests/QueryViewModelTests.swift+13
ParseableViewer/App/AppState.swift+6
@@ -237,6 +237,12 @@ final class AppState {
237237 streamLoadError = nil
238238 do {
239239 streams = try await client.listStreams()
240+ // Drop a selection that no longer exists server-side (e.g. the stream
241+ // was deleted from another client); otherwise the detail tabs keep
242+ // querying a missing stream and surface 404s.
243+ if let selected = selectedStream, !streams.contains(where: { $0.name == selected }) {
244+ selectedStream = nil
245+ }
240246 } catch {
241247 let message = ParseableError.userFriendlyMessage(for: error)
242248 streamLoadError = message
ParseableViewer/ViewModels/LiveTailViewModel.swift+29−1
@@ -48,6 +48,10 @@ final class LiveTailViewModel {
4848 /// (which would let the two interleave and write `lastTimestamp` /
4949 /// `consecutiveErrors` out of order).
5050 private var isPolling = false
51+ /// Bumped by start()/stop()/clear() so an in-flight poll whose network
52+ /// round-trip outlives one of those transitions can detect it was superseded
53+ /// and drop its results instead of repopulating a torn-down/cleared tail.
54+ private var pollGeneration = 0
5155
5256 deinit {
5357 timer?.invalidate()
@@ -56,6 +60,9 @@ final class LiveTailViewModel {
5660 // Cached formatters — nonisolated(unsafe) so static methods can use them from Task.detached
5761 nonisolated(unsafe) private static let displayFormatter: DateFormatter = {
5862 let f = DateFormatter()
63+ // Fixed-format formatters must use en_US_POSIX so a non-Gregorian or
64+ // non-Latin-digit system locale doesn't render wrong numerals/era.
65+ f.locale = Locale(identifier: "en_US_POSIX")
5966 f.dateFormat = "HH:mm:ss.SSS"
6067 return f
6168 }()
@@ -168,6 +175,7 @@ final class LiveTailViewModel {
168175 let storedMax = UserDefaults.standard.integer(forKey: "liveTailMaxEntries")
169176 if storedMax > 0 { maxEntries = storedMax }
170177
178+ pollGeneration += 1
171179 isRunning = true
172180 isPaused = false
173181 errorMessage = nil
@@ -195,6 +203,7 @@ final class LiveTailViewModel {
195203 }
196204
197205 func stop() {
206+ pollGeneration += 1
198207 timer?.invalidate()
199208 timer = nil
200209 isRunning = false
@@ -206,8 +215,14 @@ final class LiveTailViewModel {
206215 }
207216
208217 func clear() {
218+ pollGeneration += 1
209219 entries = []
210220 seenFingerprints = []
221+ // Advance the look-back floor to now: without this, the next poll queries
222+ // from the old `lastTimestamp` with an empty fingerprint set and re-inserts
223+ // every just-cleared record still inside the ~90 s window, repopulating the
224+ // view the user just emptied. Matches start()'s reset.
225+ lastTimestamp = Date()
211226 allKnownKeys = []
212227 droppedCount = 0
213228 columns = []
@@ -355,6 +370,7 @@ final class LiveTailViewModel {
355370 guard !isPolling else { return }
356371 isPolling = true
357372 defer { isPolling = false }
373+ let generation = pollGeneration
358374
359375 let now = Date()
360376 // The Parseable server truncates query time-ranges to minute
@@ -374,6 +390,11 @@ final class LiveTailViewModel {
374390 do {
375391 let records = try await client.query(sql: sql, startTime: queryStart, endTime: now)
376392
393+ // A stop()/clear()/start during the network round-trip supersedes this
394+ // poll; don't write stale results to a torn-down or cleared tail. Also
395+ // bail if the user paused mid-flight.
396+ guard isRunning, !isPaused, generation == pollGeneration else { return }
397+
377398 // Process records off the main actor using a snapshot of seen fingerprints
378399 let seenSnapshot = seenFingerprints
379400 let pollTime = now
@@ -395,6 +416,10 @@ final class LiveTailViewModel {
395416 return results
396417 }.value
397418
419+ // Re-check after the detached processing hop: a stop/clear could have
420+ // landed while we were off the main actor.
421+ guard isRunning, !isPaused, generation == pollGeneration else { return }
422+
398423 // Authoritative dedup on main actor
399424 var newEntries: [LiveTailEntry] = []
400425 for entry in candidateEntries {
@@ -451,7 +476,10 @@ final class LiveTailViewModel {
451476 errorMessage = nil
452477 consecutiveErrors = 0
453478 } catch {
454− guard !Task.isCancelled else { return }
479+ // Drop the error if this poll was cancelled or superseded by a
480+ // stop/clear/start — don't bump the failure counter or surface an
481+ // error on a tail the user already tore down.
482+ guard !Task.isCancelled, isRunning, generation == pollGeneration else { return }
455483 consecutiveErrors += 1
456484 if consecutiveErrors >= Self.maxConsecutiveErrors {
457485 errorMessage = "\(ParseableError.userFriendlyMessage(for: error)) — stopped after \(consecutiveErrors) consecutive failures"
ParseableViewer/ViewModels/QueryViewModel.swift+12−1
@@ -497,7 +497,7 @@ final class QueryViewModel {
497497 guard !results.isEmpty else { return "[]" }
498498 let encoder = JSONEncoder()
499499 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
500− if let data = try? encoder.encode(results),
500+ if let data = try? encoder.encode(Self.projectRecords(results, to: visibleColumns)),
501501 let string = String(data: data, encoding: .utf8) {
502502 return string
503503 }
@@ -508,6 +508,17 @@ final class QueryViewModel {
508508 Self.buildCSV(records: results, columns: visibleColumns)
509509 }
510510
511+ /// Projects each record down to the given columns so JSON export, like CSV,
512+ /// respects hidden columns instead of leaking every field. Safe to call from
513+ /// any thread.
514+ static func projectRecords(_ records: [LogRecord], to columns: [String]) -> [LogRecord] {
515+ // No column list means no restriction (columns not computed yet) — export
516+ // the full records rather than stripping every field to an empty object.
517+ guard !columns.isEmpty else { return records }
518+ let allowed = Set(columns)
519+ return records.map { record in record.filter { allowed.contains($0.key) } }
520+ }
521+
511522 /// Builds a CSV string from records and columns. Safe to call from any thread.
512523 static func buildCSV(records: [LogRecord], columns: [String]) -> String {
513524 guard !records.isEmpty, !columns.isEmpty else { return "" }
ParseableViewer/Views/QueryView.swift+36−15
@@ -1,5 +1,15 @@
11import SwiftUI
22
3+/// Composite of the inputs that decide which query QueryView should run. Watched
4+/// by a single `.onChange` so a stream switch and a pending saved-filter (which
5+/// arrive in the same update) are handled together in one pass — two separate
6+/// `.onChange` handlers would race and could clobber the filter with the default
7+/// query depending on their firing order.
8+private struct QueryViewContext: Equatable {
9+ let stream: String?
10+ let pendingSQL: String?
11+}
12+
313struct QueryView: View {
414 @Environment(AppState.self) private var appState
515 @State private var viewModel = QueryViewModel()
@@ -251,19 +261,33 @@ struct QueryView: View {
251261 )
252262 }
253263 }
254− .onChange(of: appState.selectedStream) { oldValue, newValue in
255− viewModel.clearResults()
256− if let stream = newValue {
257− let didSetDefault = viewModel.setDefaultQuery(stream: stream, previousStream: oldValue)
264+ .onChange(of: QueryViewContext(stream: appState.selectedStream, pendingSQL: appState.pendingFilterSQL)) { old, new in
265+ guard let stream = new.stream else {
266+ viewModel.clearResults()
267+ return
268+ }
269+ let streamChanged = old.stream != new.stream
270+ if streamChanged {
271+ viewModel.clearResults()
258272 Task {
259273 await viewModel.loadSchema(client: appState.client, stream: stream)
260274 }
275+ }
276+ // A pending saved filter (set together with the stream switch, or on
277+ // its own for the already-selected stream) takes priority over the
278+ // default query. Consuming it here in the same handler that owns the
279+ // stream switch avoids the previous two-handler race.
280+ if let sql = appState.pendingFilterSQL {
281+ appState.pendingFilterSQL = nil
282+ viewModel.sqlQuery = sql
283+ Task {
284+ await viewModel.executeQuery(client: appState.client, stream: stream)
285+ }
286+ } else if streamChanged {
287+ let didSetDefault = viewModel.setDefaultQuery(stream: stream, previousStream: old.stream)
261288 if didSetDefault {
262289 Task {
263− await viewModel.executeQuery(
264− client: appState.client,
265− stream: stream
266− )
290+ await viewModel.executeQuery(client: appState.client, stream: stream)
267291 }
268292 }
269293 }
@@ -324,12 +348,6 @@ struct QueryView: View {
324348 )
325349 }
326350 }
327− .onChange(of: appState.pendingFilterSQL) { _, newSQL in
328− if let sql = newSQL {
329− viewModel.sqlQuery = sql
330− appState.pendingFilterSQL = nil
331− }
332− }
333351 .alert("Export Error", isPresented: $showExportError) {
334352 Button("OK") {}
335353 } message: {
@@ -395,7 +413,10 @@ struct QueryView: View {
395413 } else {
396414 let encoder = JSONEncoder()
397415 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
398− content = (try? encoder.encode(results))
416+ // Project to visible columns so JSON export matches CSV and
417+ // doesn't leak hidden fields.
418+ let projected = QueryViewModel.projectRecords(results, to: columns)
419+ content = (try? encoder.encode(projected))
399420 .flatMap { String(data: $0, encoding: .utf8) }
400421 }
401422 guard let content else {
ParseableViewerTests/QueryViewModelTests.swift+13
@@ -138,6 +138,19 @@ final class QueryViewModelTests: XCTestCase {
138138 XCTAssertTrue(json.contains("\"value\""))
139139 }
140140
141+ func testProjectRecordsExcludesHiddenColumns() {
142+ let records: [LogRecord] = [["visible": .string("v"), "hidden": .string("secret")]]
143+ let projected = QueryViewModel.projectRecords(records, to: ["visible"])
144+ XCTAssertEqual(projected.first?.keys.sorted(), ["visible"])
145+ XCTAssertNil(projected.first?["hidden"], "Hidden column must not leak into export")
146+ }
147+
148+ func testProjectRecordsEmptyColumnsKeepsAllFields() {
149+ let records: [LogRecord] = [["a": .int(1), "b": .int(2)]]
150+ let projected = QueryViewModel.projectRecords(records, to: [])
151+ XCTAssertEqual(projected.first?.keys.sorted(), ["a", "b"])
152+ }
153+
141154 // MARK: - Time range
142155
143156 func testTimeRangeOptionsAllHaveValues() {