From e1cf35c576956bd9175470e7d2becb51eb7a8896 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 7 Sep 2026 13:55:50 +0700 Subject: [PATCH 1/4] test(datagrid): wait for the grid's rows before clicking one in the inspector suites Claude-Session: https://claude.ai/code/session_01GKojw3udSpBimqV6HwHGH2 --- TableProUITests/ForeignKeyPickerUITests.swift | 2 +- .../InspectorFieldAffordanceUITests.swift | 15 +++++++++++++++ TableProUITests/JSONRowInspectorUITests.swift | 2 +- TableProUITests/Support/UITestCase.swift | 15 +++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/TableProUITests/ForeignKeyPickerUITests.swift b/TableProUITests/ForeignKeyPickerUITests.swift index 7e9c68018f..159dce0752 100644 --- a/TableProUITests/ForeignKeyPickerUITests.swift +++ b/TableProUITests/ForeignKeyPickerUITests.swift @@ -72,7 +72,7 @@ final class ForeignKeyPickerUITests: UITestCase { let grid = window.tables.matching(identifier: "data-grid").firstMatch XCTAssertTrue(grid.waitToExist(timeout: 30), "Album produced no data grid") XCTAssertTrue( - waitForPredicate(timeout: 30) { !grid.tableRows.allElementsBoundByIndex.isEmpty }, + waitForClickableRows(in: grid), "Album must load rows before a cell can be edited" ) return grid diff --git a/TableProUITests/InspectorFieldAffordanceUITests.swift b/TableProUITests/InspectorFieldAffordanceUITests.swift index e1ab438a13..2e1ae6daa2 100644 --- a/TableProUITests/InspectorFieldAffordanceUITests.swift +++ b/TableProUITests/InspectorFieldAffordanceUITests.swift @@ -57,10 +57,25 @@ final class InspectorFieldAffordanceUITests: UITestCase { /// the grid on the 1024x768 runner, and `dy` clears the 42pt header so the click does not open /// the column menu. The row is selected before the inspector opens, so the reveal cannot move /// the grid out from under the coordinate. + /// + /// The rows have to be in before the click, or it lands on empty grid and selects nothing: the + /// inspector then opens on its no-selection state, which draws no field and so no value menu, + /// and the failure reads as a missing menu rather than as a missed row. That is what made this + /// suite fail on a contended runner and pass on its retry. private func openFirstTableRow(in app: XCUIApplication, window: XCUIElement) throws -> XCUIElement { let grid = window.tables.matching(identifier: "data-grid").firstMatch XCTAssertTrue(grid.waitToExist(timeout: 30), "The sample table must produce a grid") + XCTAssertTrue( + waitForClickableRows(in: grid), + "The sample table must have its rows in before one of them can be clicked" + ) + gridPoint(in: grid, of: window, dy: 70).click() + XCTAssertTrue( + waitForPredicate(timeout: 10) { grid.tableRows.allElementsBoundByIndex.contains { $0.isSelected } }, + "The click must select a row, or the inspector has nothing to draw fields for" + ) + showInspector(in: app) return grid } diff --git a/TableProUITests/JSONRowInspectorUITests.swift b/TableProUITests/JSONRowInspectorUITests.swift index e6ab28e028..3564e20711 100644 --- a/TableProUITests/JSONRowInspectorUITests.swift +++ b/TableProUITests/JSONRowInspectorUITests.swift @@ -71,7 +71,7 @@ final class JSONRowInspectorUITests: UITestCase { let grid = window.tables.matching(identifier: "data-grid").firstMatch XCTAssertTrue(grid.waitToExist(timeout: 30), "Album produced no data grid") XCTAssertTrue( - waitForPredicate(timeout: 30) { !grid.tableRows.allElementsBoundByIndex.isEmpty }, + waitForClickableRows(in: grid), "Album must load rows before a row can be inspected" ) return grid diff --git a/TableProUITests/Support/UITestCase.swift b/TableProUITests/Support/UITestCase.swift index e171eed920..95d7ce1e11 100644 --- a/TableProUITests/Support/UITestCase.swift +++ b/TableProUITests/Support/UITestCase.swift @@ -226,6 +226,21 @@ internal class UITestCase: XCTestCase { .withOffset(CGVector(dx: max(80, clearOfBrowser), dy: dy)) } + /// The preconditions a click taken off the grid actually has, which existence does not give. + /// + /// The grid enters the tree when its table view is mounted, which is before the query behind it + /// has returned. A click posted then lands on empty grid and selects nothing, and nothing fails + /// there: the suite goes on to wait out its own timeout for whatever the selection was supposed + /// to produce, and reports that as the missing thing. A grid that exists is also not laid out + /// yet, and a coordinate taken off an empty frame resolves to `(inf, inf)`, which posts at no + /// display at all and takes the runner down instead of failing. + internal func waitForClickableRows(in grid: XCUIElement, timeout: TimeInterval = 30) -> Bool { + waitForPredicate(timeout: timeout) { + grid.frame.width > 0 && grid.frame.height > 0 + && !grid.tableRows.allElementsBoundByIndex.isEmpty + } + } + /// The object browser draws its rows as hosted cells, so a row's name arrives as the static /// text's `value`, carrying the object kind the row reads out to VoiceOver, rather than as a /// label or an identifier. Matching on `value` is what finds them. From f770aef8c3065d3eeff39e96506e99b6e352c78a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 7 Sep 2026 13:55:56 +0700 Subject: [PATCH 2/4] ci: keep a failed result-bundle upload from failing the suite that produced it Claude-Session: https://claude.ai/code/session_01GKojw3udSpBimqV6HwHGH2 --- .github/workflows/macos-tests.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/macos-tests.yml b/.github/workflows/macos-tests.yml index fe046ed19a..58d33db739 100644 --- a/.github/workflows/macos-tests.yml +++ b/.github/workflows/macos-tests.yml @@ -341,8 +341,12 @@ jobs: if: ${{ !cancelled() }} run: scripts/ci/summarize-xcresult.sh TestResults.xcresult "Unit tests" + # The bundle is evidence about the run, not the run's verdict, so a failure to upload it + # must not decide whether the suite passed. GitHub's own artifact endpoint answered + # ENOTFOUND on run 34056832569 and turned a green suite red, which failed the gate on main. - name: Upload test results if: ${{ !cancelled() }} + continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: macos-test-results @@ -422,8 +426,11 @@ jobs: if: ${{ !cancelled() }} run: scripts/ci/summarize-xcresult.sh UITestResults.xcresult "UI tests ${{ matrix.shard }}/${{ matrix.of }}" + # Diagnostic, exactly as in the unit job above: an upload that cannot reach GitHub says + # nothing about the tests and must not fail the shard. - name: Upload UI test results if: ${{ !cancelled() }} + continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: macos-ui-test-results-${{ matrix.shard }} From 4813947d3302438590480a1dc9438935c36e1625 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 7 Sep 2026 15:19:03 +0700 Subject: [PATCH 3/4] test(datagrid): stop the row wait at the first row instead of resolving every one Claude-Session: https://claude.ai/code/session_01GKojw3udSpBimqV6HwHGH2 --- .../InspectorFieldAffordanceUITests.swift | 5 ----- TableProUITests/Support/UITestCase.swift | 13 ++++++++++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/TableProUITests/InspectorFieldAffordanceUITests.swift b/TableProUITests/InspectorFieldAffordanceUITests.swift index 2e1ae6daa2..ccd1fc2224 100644 --- a/TableProUITests/InspectorFieldAffordanceUITests.swift +++ b/TableProUITests/InspectorFieldAffordanceUITests.swift @@ -71,11 +71,6 @@ final class InspectorFieldAffordanceUITests: UITestCase { ) gridPoint(in: grid, of: window, dy: 70).click() - XCTAssertTrue( - waitForPredicate(timeout: 10) { grid.tableRows.allElementsBoundByIndex.contains { $0.isSelected } }, - "The click must select a row, or the inspector has nothing to draw fields for" - ) - showInspector(in: app) return grid } diff --git a/TableProUITests/Support/UITestCase.swift b/TableProUITests/Support/UITestCase.swift index 95d7ce1e11..fb9f4db6a8 100644 --- a/TableProUITests/Support/UITestCase.swift +++ b/TableProUITests/Support/UITestCase.swift @@ -234,10 +234,17 @@ internal class UITestCase: XCTestCase { /// to produce, and reports that as the missing thing. A grid that exists is also not laid out /// yet, and a coordinate taken off an empty frame resolves to `(inf, inf)`, which posts at no /// display at all and takes the runner down instead of failing. + /// + /// The question has to stop at the first row. `allElementsBoundByIndex` resolves the whole set, + /// and asking the grid for its rows is what activates `DataGridCellAccessibilityView`, so the + /// table view then prepares every row of the page and mounts a cell view for each: fine on + /// Album's 347 rows, and past XCUITest's own query budget on the `Track` table the sample opens + /// by default, where it fails the suite with "Timed out while evaluating UI query" rather than + /// with an assertion. `firstMatch` is what stops the traversal early. internal func waitForClickableRows(in grid: XCUIElement, timeout: TimeInterval = 30) -> Bool { - waitForPredicate(timeout: timeout) { - grid.frame.width > 0 && grid.frame.height > 0 - && !grid.tableRows.allElementsBoundByIndex.isEmpty + let firstRow = grid.tableRows.firstMatch + return waitForPredicate(timeout: timeout) { + grid.frame.width > 0 && grid.frame.height > 0 && firstRow.exists } } From 86c2360027fad8ea8bd34546b4e4da3e84934c27 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 7 Sep 2026 16:16:27 +0700 Subject: [PATCH 4/4] fix(ios): reap the query Live Activity the app leaves behind when it is quit mid-query Claude-Session: https://claude.ai/code/session_01DeXDeLTqEXdRgsPkvPkdPP --- CHANGELOG.md | 4 + .../TableProDatabase/ConnectionManager.swift | 6 +- TableProMobile/TableProMobile/AppState.swift | 1 + .../Models/QueryExecutionOutcome.swift | 55 ++ .../BackgroundReleaseCoordinator.swift | 8 +- .../Platform/QueryActivityController.swift | 240 +++++++++ .../TableProMobile/TableProMobileApp.swift | 8 +- .../ViewModels/QueryEditorViewModel.swift | 4 +- .../Views/QueryEditorView.swift | 128 ++--- .../Mocks/MockDatabaseDriver.swift | 2 + .../QueryActivityControllerTests.swift | 477 ++++++++++++++++++ .../QueryEditorViewModelTests.swift | 55 ++ .../QueryExecutionOutcomeTests.swift | 54 ++ .../QueryLiveActivityWidget.swift | 92 +++- .../Shared/QueryActivityAttributes.swift | 54 +- docs/ios/index.mdx | 4 +- 16 files changed, 1083 insertions(+), 109 deletions(-) create mode 100644 TableProMobile/TableProMobile/Models/QueryExecutionOutcome.swift create mode 100644 TableProMobile/TableProMobile/Platform/QueryActivityController.swift create mode 100644 TableProMobile/TableProMobileTests/QueryActivityControllerTests.swift create mode 100644 TableProMobile/TableProMobileTests/QueryExecutionOutcomeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d4837e1557..1aeef7aef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Query Live Activity still counting up on the iOS Lock Screen and Dynamic Island after the app was quit mid-query. +- Stop leaving an iOS MySQL or Redis query running, with the spinner and the Live Activity stuck behind it. +- A stopped or memory-stopped iOS query recorded in Query History as successful. +- Query Live Activity marked interrupted while an iOS query longer than five minutes was still running. - Export and Transfer To preselecting a same-named table from another schema, or nothing at all. - Delete queuing a table drop from the menu bar with none of the confirmation the sidebar asks for. - Truncate Table offered from the menu bar for a view, which the server then refuses. diff --git a/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift index 8a3b10d0b3..9b9afe0308 100644 --- a/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift +++ b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift @@ -86,14 +86,16 @@ public final class ConnectionManager: @unchecked Sendable { !suspensionBlockingIds().isEmpty } - public func releaseSuspensionBlockingResources() async { + @discardableResult + public func releaseSuspensionBlockingResources() async -> [UUID] { let ids = suspensionBlockingIds() - guard !ids.isEmpty else { return } + guard !ids.isEmpty else { return [] } await withTaskGroup(of: Void.self) { group in for id in ids { group.addTask { await self.disconnect(id) } } } + return ids } private func suspensionBlockingIds() -> [UUID] { diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index 4a9f3257ff..dc5dbf53c6 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -33,6 +33,7 @@ final class AppState { var pendingImportURL: URL? let connectionManager: ConnectionManager let backgroundRelease: BackgroundReleaseCoordinator + let queryActivities = QueryActivityController() let syncCoordinator = IOSSyncCoordinator() let sshProvider: IOSSSHProvider let secureStore: KeychainSecureStore diff --git a/TableProMobile/TableProMobile/Models/QueryExecutionOutcome.swift b/TableProMobile/TableProMobile/Models/QueryExecutionOutcome.swift new file mode 100644 index 0000000000..01fb52c6d7 --- /dev/null +++ b/TableProMobile/TableProMobile/Models/QueryExecutionOutcome.swift @@ -0,0 +1,55 @@ +import Foundation +import TableProModels + +nonisolated enum QueryExecutionOutcome: Equatable, Sendable { + case completed + case failed + case stopped + case interrupted + + init(phase: QueryEditorViewModel.Phase) { + switch phase { + case .finished: + self = .completed + case .error: + self = .failed + case .truncated(let reason): + self = Self(truncation: reason) + case .idle, .running: + self = .interrupted + } + } + + private init(truncation: TruncationReason) { + switch truncation { + case .rowCap, .driverLimit: + self = .completed + case .cancelled: + self = .stopped + case .memoryPressure: + self = .interrupted + @unknown default: + self = .interrupted + } + } + + var activityOutcome: QueryActivityAttributes.Outcome { + switch self { + case .completed: .completed + case .failed: .failed + case .stopped: .stopped + case .interrupted: .interrupted + } + } + + var historyMessage: String? { + switch self { + case .completed, .failed: + nil + case .stopped: + String(localized: "Stopped before the query finished.") + case .interrupted: + String(localized: "Interrupted before the query finished.") + } + } +} diff --git a/TableProMobile/TableProMobile/Platform/BackgroundReleaseCoordinator.swift b/TableProMobile/TableProMobile/Platform/BackgroundReleaseCoordinator.swift index 6ff8b92be0..ff35d4fda5 100644 --- a/TableProMobile/TableProMobile/Platform/BackgroundReleaseCoordinator.swift +++ b/TableProMobile/TableProMobile/Platform/BackgroundReleaseCoordinator.swift @@ -40,17 +40,19 @@ final class BackgroundReleaseCoordinator { syncAssertion() } - func releaseForSuspension() async { + @discardableResult + func releaseForSuspension() async -> [UUID] { isPreparedForSuspension = false guard connectionManager.hasSuspensionBlockingResources else { syncAssertion() - return + return [] } beginAssertion() releasesInFlight += 1 - await connectionManager.releaseSuspensionBlockingResources() + let released = await connectionManager.releaseSuspensionBlockingResources() releasesInFlight -= 1 syncAssertion() + return released } private var needsAssertion: Bool { diff --git a/TableProMobile/TableProMobile/Platform/QueryActivityController.swift b/TableProMobile/TableProMobile/Platform/QueryActivityController.swift new file mode 100644 index 0000000000..9e798dc136 --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/QueryActivityController.swift @@ -0,0 +1,240 @@ +import ActivityKit +import Foundation +import os +import UIKit + +@MainActor +protocol LiveActivityHandle { + var id: String { get } + var state: QueryActivityAttributes.ContentState { get } + func update(state: QueryActivityAttributes.ContentState, staleDate: Date?) async + func end(state: QueryActivityAttributes.ContentState) async +} + +@MainActor +protocol LiveActivityStore { + var areActivitiesEnabled: Bool { get } + var liveActivities: [any LiveActivityHandle] { get } + func request( + attributes: QueryActivityAttributes, + state: QueryActivityAttributes.ContentState, + staleDate: Date? + ) throws -> any LiveActivityHandle +} + +struct QueryExecutionToken: Hashable, Sendable { + fileprivate let id: UUID + + fileprivate init() { + id = UUID() + } +} + +@MainActor +final class QueryActivityController { + private static let logger = Logger(subsystem: "com.TablePro", category: "QueryActivity") + private static let taskName = "End query Live Activity" + private static let heartbeatInterval = QueryActivityStaleWindow.seconds / 2 + + private struct Execution { + let connectionId: UUID + let handle: any LiveActivityHandle + var lastUpdatedAt: Date + } + + private let store: any LiveActivityStore + private let asserter: any BackgroundTaskAsserting + private let now: () -> Date + + private var executions: [QueryExecutionToken: Execution] = [:] + private var taskIdentifier: UIBackgroundTaskIdentifier = .invalid + private var assertionHolders = 0 + private var reapTask: Task? + + init( + store: any LiveActivityStore = ActivityKitLiveActivityStore(), + asserter: any BackgroundTaskAsserting = UIApplication.shared, + now: @escaping () -> Date = Date.init + ) { + self.store = store + self.asserter = asserter + self.now = now + } + + var ownedActivityIds: Set { + Set(executions.values.map(\.handle.id)) + } + + func reapOrphans() async { + if let inFlight = reapTask { + return await inFlight.value + } + let task = Task { await self.endOrphans() } + reapTask = task + await task.value + reapTask = nil + } + + func start( + connectionId: UUID, + connectionName: String, + query: String, + startedAt: Date + ) async -> QueryExecutionToken? { + await reapOrphans() + guard store.areActivitiesEnabled else { return nil } + + let attributes = QueryActivityAttributes( + connectionId: connectionId, + connectionName: connectionName, + queryPreview: preview(for: query) + ) + let state = QueryActivityAttributes.ContentState(startedAt: startedAt, lastUpdatedAt: startedAt) + do { + let handle = try store.request( + attributes: attributes, + state: state, + staleDate: startedAt.addingTimeInterval(QueryActivityStaleWindow.seconds) + ) + let token = QueryExecutionToken() + executions[token] = Execution(connectionId: connectionId, handle: handle, lastUpdatedAt: startedAt) + return token + } catch { + Self.logger.warning("Could not start the query Live Activity: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + func update(token: QueryExecutionToken?, rowsStreamed: Int) async { + guard let token, let execution = executions[token] else { return } + let instant = now() + let rowsChanged = execution.handle.state.rowsStreamed != rowsStreamed + let heartbeatDue = instant.timeIntervalSince(execution.lastUpdatedAt) >= Self.heartbeatInterval + guard rowsChanged || heartbeatDue else { return } + + var state = execution.handle.state + state.rowsStreamed = rowsStreamed + state.lastUpdatedAt = instant + executions[token]?.lastUpdatedAt = instant + await execution.handle.update( + state: state, + staleDate: instant.addingTimeInterval(QueryActivityStaleWindow.seconds) + ) + } + + func end(token: QueryExecutionToken?, outcome: QueryActivityAttributes.Outcome) async { + guard let token, let execution = executions.removeValue(forKey: token) else { return } + await end(execution: execution, outcome: outcome) + } + + func endEverything(forConnection connectionId: UUID, outcome: QueryActivityAttributes.Outcome) async { + let matching = executions.filter { $0.value.connectionId == connectionId } + guard !matching.isEmpty else { return } + for token in matching.keys { + executions.removeValue(forKey: token) + } + for execution in matching.values { + await end(execution: execution, outcome: outcome) + } + } + + private func end(execution: Execution, outcome: QueryActivityAttributes.Outcome) async { + beginAssertion() + defer { endAssertion() } + await execution.handle.end(state: execution.handle.state.ended(as: outcome, at: now())) + } + + // MARK: - Orphans + + private func endOrphans() async { + let owned = ownedActivityIds + let orphans = store.liveActivities.filter { !owned.contains($0.id) } + guard !orphans.isEmpty else { return } + Self.logger.info("Ending \(orphans.count, privacy: .public) orphaned query Live Activities") + beginAssertion() + defer { endAssertion() } + for orphan in orphans { + await orphan.end(state: orphan.state.ended(as: .interrupted, at: now())) + } + } + + // MARK: - Privacy + + private func preview(for query: String) -> String { + guard !AppPreferences.hidesQueryPreviewInActivity else { + return String(localized: "Running query") + } + return String(query.prefix(60)) + } + + // MARK: - Background assertion + + private func beginAssertion() { + assertionHolders += 1 + guard taskIdentifier == .invalid else { return } + taskIdentifier = asserter.beginBackgroundTask(name: Self.taskName) { [weak self] in + self?.expireAssertion() + } + } + + private func endAssertion() { + assertionHolders = max(0, assertionHolders - 1) + guard assertionHolders == 0 else { return } + releaseAssertion() + } + + private func expireAssertion() { + Self.logger.warning("Background time expired before the query Live Activity was ended") + assertionHolders = 0 + releaseAssertion() + } + + private func releaseAssertion() { + guard taskIdentifier != .invalid else { return } + asserter.endBackgroundTask(taskIdentifier) + taskIdentifier = .invalid + } +} + +// MARK: - ActivityKit + +@MainActor +struct ActivityKitLiveActivityStore: LiveActivityStore { + var areActivitiesEnabled: Bool { + ActivityAuthorizationInfo().areActivitiesEnabled + } + + var liveActivities: [any LiveActivityHandle] { + Activity.activities.map(ActivityKitHandle.init) + } + + func request( + attributes: QueryActivityAttributes, + state: QueryActivityAttributes.ContentState, + staleDate: Date? + ) throws -> any LiveActivityHandle { + let activity = try Activity.request( + attributes: attributes, + content: .init(state: state, staleDate: staleDate) + ) + return ActivityKitHandle(activity: activity) + } +} + +@MainActor +private struct ActivityKitHandle: LiveActivityHandle { + let activity: Activity + + var id: String { activity.id } + var state: QueryActivityAttributes.ContentState { activity.content.state } + + func update(state: QueryActivityAttributes.ContentState, staleDate: Date?) async { + nonisolated(unsafe) let target = activity + await target.update(.init(state: state, staleDate: staleDate)) + } + + func end(state: QueryActivityAttributes.ContentState) async { + nonisolated(unsafe) let target = activity + await target.end(.init(state: state, staleDate: nil), dismissalPolicy: .immediate) + } +} diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index 87d29d5cf9..92ffc4c37b 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -77,6 +77,7 @@ struct TableProMobileApp: App { lockState.handleScenePhase(phase) switch phase { case .active: + Task { await appState.queryActivities.reapOrphans() } appState.backgroundRelease.cancelPreparation() MemoryPressureMonitor.shared.start() appState.retryLoadIfFailed() @@ -105,7 +106,12 @@ struct TableProMobileApp: App { heartbeatTask?.cancel() heartbeatTask = nil heartbeatService = nil - Task { await appState.backgroundRelease.releaseForSuspension() } + Task { + let released = await appState.backgroundRelease.releaseForSuspension() + for connectionId in released { + await appState.queryActivities.endEverything(forConnection: connectionId, outcome: .interrupted) + } + } scheduleBackgroundSync() default: break diff --git a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift index eb8c5e3be8..42e93ca3f9 100644 --- a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift @@ -6,7 +6,7 @@ import TableProModels @MainActor @Observable final class QueryEditorViewModel { - enum Phase: Sendable { + nonisolated enum Phase: Sendable { case idle case running case finished @@ -105,6 +105,8 @@ final class QueryEditorViewModel { } func stop() { + guard case .running = phase else { return } + buffer.markTruncated(.cancelled) fetchTask?.cancel() } diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 29f27856aa..6688a72c3e 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -1,4 +1,3 @@ -import ActivityKit import os import SwiftUI import TableProDatabase @@ -7,6 +6,7 @@ import TableProQuery struct QueryEditorView: View { @Environment(ConnectionCoordinator.self) private var coordinator + @Environment(AppState.self) private var appState private static let logger = Logger(subsystem: "com.TablePro", category: "QueryEditorView") @@ -128,6 +128,7 @@ struct QueryEditorView: View { Button { if isExecuting { executeTask?.cancel() + viewModel.stop() Task { try? await session?.driver.cancelCurrentQuery() } } else { executeTask = Task { await executeQuery() } @@ -423,108 +424,79 @@ struct QueryEditorView: View { isExecuting = true let startedAt = Date() executionStartTime = startedAt - let activity = startQueryActivity(trimmed: trimmed, startedAt: startedAt) - let progressUpdater = startActivityProgressUpdater(activity: activity, startedAt: startedAt) - defer { - progressUpdater.cancel() + appError = nil + + let token = await appState.queryActivities.start( + connectionId: connectionId, + connectionName: coordinator.displayName, + query: trimmed, + startedAt: startedAt + ) + + guard !Task.isCancelled else { isExecuting = false executionStartTime = nil - endQueryActivity(activity, startedAt: startedAt) + await appState.queryActivities.end(token: token, outcome: .stopped) + recordHistory(query: trimmed, outcome: .stopped, errorMessage: QueryExecutionOutcome.stopped.historyMessage) + return } - appError = nil + + let progressUpdater = startActivityProgressUpdater(token: token) await viewModel.run(driver: session.driver, query: trimmed) - if case .error(let err) = viewModel.phase { + progressUpdater.cancel() + let phase = viewModel.phase + let outcome = QueryExecutionOutcome(phase: phase) + let elapsed = viewModel.executionTime + isExecuting = false + executionStartTime = nil + + await appState.queryActivities.end(token: token, outcome: outcome.activityOutcome) + + if case .error(let err) = phase { appError = err hapticError.toggle() - coordinator.addHistoryItem( - QueryHistoryItem( - query: trimmed, - connectionId: connectionId, - wasSuccessful: false, - errorMessage: err.localizedDescription - ) - ) + recordHistory(query: trimmed, outcome: outcome, errorMessage: err.localizedDescription) + return + } + + executionTime = elapsed + + guard outcome == .completed else { + recordHistory(query: trimmed, outcome: outcome, errorMessage: outcome.historyMessage) return } - executionTime = viewModel.executionTime hapticSuccess.toggle() IOSAnalyticsProvider.shared.markFirstQueryExecuted() - let item = QueryHistoryItem(query: trimmed, connectionId: connectionId) - coordinator.addHistoryItem(item) + recordHistory(query: trimmed, outcome: outcome, errorMessage: nil) } - // MARK: - Live Activity - - private func startQueryActivity(trimmed: String, startedAt: Date) -> Activity? { - guard ActivityAuthorizationInfo().areActivitiesEnabled else { return nil } - let preview: String = AppPreferences.hidesQueryPreviewInActivity - ? String(localized: "Running query") - : String(trimmed.prefix(60)) - let attributes = QueryActivityAttributes( - connectionId: coordinator.connection.id, - connectionName: coordinator.displayName, - queryPreview: preview - ) - let initialState = QueryActivityAttributes.ContentState( - startedAt: startedAt, - endedAt: nil, - rowsStreamed: 0 - ) - // 5-minute stale window: if the app crashes mid-query, iOS marks the - // activity stale instead of showing a forever-ticking timer. - return try? Activity.request( - attributes: attributes, - content: .init(state: initialState, staleDate: startedAt.addingTimeInterval(5 * 60)) + private func recordHistory(query: String, outcome: QueryExecutionOutcome, errorMessage: String?) { + coordinator.addHistoryItem( + QueryHistoryItem( + query: query, + connectionId: connectionId, + wasSuccessful: outcome == .completed, + errorMessage: errorMessage + ) ) } - /// Polls the streaming row count once per second while the query runs and pushes - /// `activity.update(state:)` only when the count changes. The system rate-limits - /// activity updates anyway, and the lock screen card just needs a fresh number - /// when the user wakes the device mid-query - it does not need real-time ticks - /// for the count (the elapsed time ticks itself via `Text(timerInterval:)`). - private func startActivityProgressUpdater( - activity: Activity?, - startedAt: Date - ) -> Task { + // MARK: - Live Activity + + private func startActivityProgressUpdater(token: QueryExecutionToken?) -> Task { + let controller = appState.queryActivities return Task { [weak viewModel] in - guard let activity else { return } - nonisolated(unsafe) let liveActivity = activity - var lastReportedCount = 0 while !Task.isCancelled { try? await Task.sleep(for: .seconds(1)) if Task.isCancelled { return } - let count = viewModel?.legacyRows.count ?? 0 - guard count != lastReportedCount else { continue } - lastReportedCount = count - let state = QueryActivityAttributes.ContentState( - startedAt: startedAt, - endedAt: nil, - rowsStreamed: count - ) - await liveActivity.update(.init( - state: state, - staleDate: startedAt.addingTimeInterval(5 * 60) - )) + guard let count = viewModel?.legacyRows.count else { return } + await controller.update(token: token, rowsStreamed: count) } } } - - private func endQueryActivity(_ activity: Activity?, startedAt: Date) { - guard let activity else { return } - let final = QueryActivityAttributes.ContentState( - startedAt: startedAt, - endedAt: Date(), - rowsStreamed: viewModel.legacyRows.count - ) - nonisolated(unsafe) let liveActivity = activity - Task { - await liveActivity.end(.init(state: final, staleDate: nil), dismissalPolicy: .immediate) - } - } } diff --git a/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift b/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift index 820c71f004..54921a5ce5 100644 --- a/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift +++ b/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift @@ -33,6 +33,7 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { } var beforeDisconnect: (@Sendable () async -> Void)? + var beforeExecute: (@Sendable () async -> Void)? func connect() async throws {} func disconnect() async throws { @@ -42,6 +43,7 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { func cancelCurrentQuery() async throws {} func execute(query: String) async throws -> QueryResult { + await beforeExecute?() executedQueries.append(query) guard !scriptedExecuteResults.isEmpty else { return QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0) diff --git a/TableProMobile/TableProMobileTests/QueryActivityControllerTests.swift b/TableProMobile/TableProMobileTests/QueryActivityControllerTests.swift new file mode 100644 index 0000000000..f3b8f65a8f --- /dev/null +++ b/TableProMobile/TableProMobileTests/QueryActivityControllerTests.swift @@ -0,0 +1,477 @@ +import Foundation +@testable import TableProMobile +import Testing +import UIKit + +@MainActor +private final class SpyLiveActivityHandle: LiveActivityHandle { + let id: String + private(set) var state: QueryActivityAttributes.ContentState + private(set) var endedStates: [QueryActivityAttributes.ContentState] = [] + private(set) var updatedStaleDates: [Date?] = [] + weak var store: SpyLiveActivityStore? + + init(id: String, state: QueryActivityAttributes.ContentState) { + self.id = id + self.state = state + } + + func update(state: QueryActivityAttributes.ContentState, staleDate: Date?) async { + self.state = state + updatedStaleDates.append(staleDate) + } + + func end(state: QueryActivityAttributes.ContentState) async { + self.state = state + endedStates.append(state) + store?.forget(self) + } +} + +@MainActor +private final class SpyLiveActivityStore: LiveActivityStore { + var areActivitiesEnabled = true + var existing: [SpyLiveActivityHandle] = [] + var requested: [SpyLiveActivityHandle] = [] + var requestError: (any Error)? + private var nextId = 0 + + var liveActivities: [any LiveActivityHandle] { existing } + + func adopt(_ handles: [SpyLiveActivityHandle]) { + for handle in handles { + handle.store = self + } + existing = handles + } + + func forget(_ handle: SpyLiveActivityHandle) { + existing.removeAll { $0 === handle } + } + + func request( + attributes: QueryActivityAttributes, + state: QueryActivityAttributes.ContentState, + staleDate: Date? + ) throws -> any LiveActivityHandle { + if let requestError { + throw requestError + } + nextId += 1 + let handle = SpyLiveActivityHandle(id: "new-\(nextId)", state: state) + handle.store = self + requested.append(handle) + existing.append(handle) + return handle + } +} + +@MainActor +private final class SpyAsserter: BackgroundTaskAsserting { + private(set) var beginCount = 0 + private(set) var endCount = 0 + + func beginBackgroundTask(name: String, expirationHandler: @escaping () -> Void) -> UIBackgroundTaskIdentifier { + beginCount += 1 + return UIBackgroundTaskIdentifier(rawValue: 11) + } + + func endBackgroundTask(_ identifier: UIBackgroundTaskIdentifier) { + endCount += 1 + } +} + +private let referenceNow = Date(timeIntervalSince1970: 1_700_000_000) + +@MainActor +private func makeController( + store: SpyLiveActivityStore, + asserter: SpyAsserter = SpyAsserter() +) -> QueryActivityController { + QueryActivityController(store: store, asserter: asserter, now: { referenceNow }) +} + +@MainActor +@Suite(.serialized) +struct QueryActivityControllerTests { + @Test + func reapEndsEveryActivityLeftByAPreviousProcess() async { + let store = SpyLiveActivityStore() + let orphan = SpyLiveActivityHandle( + id: "orphan", + state: .init(startedAt: referenceNow.addingTimeInterval(-90)) + ) + store.adopt([orphan]) + let controller = makeController(store: store) + + await controller.reapOrphans() + _ = await controller.start( + connectionId: UUID(), + connectionName: "SIT", + query: "select * FROM User", + startedAt: referenceNow + ) + + #expect(orphan.endedStates.count == 1) + #expect(orphan.endedStates.first?.outcome == .interrupted) + #expect(orphan.endedStates.first?.endedAt == referenceNow) + } + + @Test + func reapPreservesTheElapsedTimeTheOrphanRecorded() async { + let store = SpyLiveActivityStore() + let startedAt = referenceNow.addingTimeInterval(-136) + let orphan = SpyLiveActivityHandle( + id: "orphan", + state: .init(startedAt: startedAt, rowsStreamed: 42) + ) + store.adopt([orphan]) + let controller = makeController(store: store) + + await controller.reapOrphans() + _ = await controller.start( + connectionId: UUID(), + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + + let final = orphan.endedStates.first + #expect(final?.startedAt == startedAt) + #expect(final?.rowsStreamed == 42) + } + + @Test + func reapNeverEndsAnActivityThisProcessOwns() async { + let store = SpyLiveActivityStore() + let controller = makeController(store: store) + let connectionId = UUID() + + _ = await controller.start( + connectionId: connectionId, + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + let owned = store.requested.first + await controller.reapOrphans() + + #expect(owned?.endedStates.isEmpty == true) + #expect(controller.ownedActivityIds.count == 1) + } + + @Test + func aSecondConnectionsActivitySurvivesAReapWhileBothRun() async { + let store = SpyLiveActivityStore() + let controller = makeController(store: store) + + _ = await controller.start( + connectionId: UUID(), + connectionName: "First", + query: "select 1", + startedAt: referenceNow + ) + _ = await controller.start( + connectionId: UUID(), + connectionName: "Second", + query: "select 2", + startedAt: referenceNow + ) + await controller.reapOrphans() + + #expect(store.requested.count == 2) + #expect(store.requested.allSatisfy { $0.endedStates.isEmpty }) + } + + @Test + func twoScenesOnOneConnectionKeepSeparateActivities() async { + let store = SpyLiveActivityStore() + let controller = makeController(store: store) + let shared = UUID() + + let first = await controller.start( + connectionId: shared, + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + let second = await controller.start( + connectionId: shared, + connectionName: "SIT", + query: "select 2", + startedAt: referenceNow + ) + + #expect(first != second) + #expect(store.requested.count == 2) + #expect(store.requested.allSatisfy { $0.endedStates.isEmpty }) + + await controller.end(token: second, outcome: .completed) + + #expect(store.requested.first?.endedStates.isEmpty == true) + #expect(controller.ownedActivityIds.count == 1) + } + + @Test + func endingOneSceneLeavesTheOtherSceneUpdatable() async { + let store = SpyLiveActivityStore() + let controller = makeController(store: store) + let shared = UUID() + + let first = await controller.start( + connectionId: shared, + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + let second = await controller.start( + connectionId: shared, + connectionName: "SIT", + query: "select 2", + startedAt: referenceNow + ) + await controller.end(token: second, outcome: .completed) + await controller.update(token: first, rowsStreamed: 5) + + #expect(store.requested.first?.state.rowsStreamed == 5) + } + + @Test + func suspensionEndsEveryActivityOnThatConnectionOnly() async { + let store = SpyLiveActivityStore() + let controller = makeController(store: store) + let doomed = UUID() + let survivor = UUID() + + _ = await controller.start( + connectionId: doomed, + connectionName: "DuckDB", + query: "select 1", + startedAt: referenceNow + ) + _ = await controller.start( + connectionId: survivor, + connectionName: "SIT", + query: "select 2", + startedAt: referenceNow + ) + + await controller.endEverything(forConnection: doomed, outcome: .interrupted) + + #expect(store.requested[0].endedStates.first?.outcome == .interrupted) + #expect(store.requested[1].endedStates.isEmpty) + } + + @Test + func aLongQueryWithNoNewRowsStillRefreshesItsStaleDate() async { + let store = SpyLiveActivityStore() + var clock = referenceNow + let controller = QueryActivityController( + store: store, + asserter: SpyAsserter(), + now: { clock } + ) + let token = await controller.start( + connectionId: UUID(), + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + + clock = referenceNow.addingTimeInterval(QueryActivityStaleWindow.seconds / 2 + 1) + await controller.update(token: token, rowsStreamed: 0) + + let handle = store.requested.first + #expect(handle?.updatedStaleDates.count == 1) + #expect(handle?.state.lastUpdatedAt == clock) + } + + @Test + func endRecordsTheOutcomeTheQueryActuallyReached() async { + let store = SpyLiveActivityStore() + let controller = makeController(store: store) + let connectionId = UUID() + + let token = await controller.start( + connectionId: connectionId, + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + await controller.end(token: token, outcome: .stopped) + + let final = store.requested.first?.endedStates.first + #expect(final?.outcome == .stopped) + #expect(final?.endedAt == referenceNow) + } + + @Test + func endHoldsABackgroundAssertionForTheWholeCall() async { + let store = SpyLiveActivityStore() + let asserter = SpyAsserter() + let controller = makeController(store: store, asserter: asserter) + let connectionId = UUID() + + let token = await controller.start( + connectionId: connectionId, + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + await controller.end(token: token, outcome: .completed) + + #expect(asserter.beginCount >= 1) + #expect(asserter.endCount == asserter.beginCount) + } + + @Test + func theStaleDateSlidesForwardWithEveryProgressUpdate() async { + let store = SpyLiveActivityStore() + let controller = makeController(store: store) + let connectionId = UUID() + let startedAt = referenceNow.addingTimeInterval(-240) + + let token = await controller.start( + connectionId: connectionId, + connectionName: "SIT", + query: "select 1", + startedAt: startedAt + ) + await controller.update(token: token, rowsStreamed: 500) + + let slid = store.requested.first?.updatedStaleDates.first ?? nil + #expect(slid == referenceNow.addingTimeInterval(QueryActivityStaleWindow.seconds)) + } + + @Test + func aRepeatedRowCountSendsNoUpdate() async { + let store = SpyLiveActivityStore() + let controller = makeController(store: store) + let connectionId = UUID() + + let token = await controller.start( + connectionId: connectionId, + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + await controller.update(token: token, rowsStreamed: 12) + await controller.update(token: token, rowsStreamed: 12) + + #expect(store.requested.first?.updatedStaleDates.count == 1) + } + + @Test + func aFailedRequestLeavesNothingOwnedSoTheNextReapIsUnaffected() async { + let store = SpyLiveActivityStore() + store.requestError = CocoaError(.fileNoSuchFile) + let controller = makeController(store: store) + let connectionId = UUID() + + let token = await controller.start( + connectionId: connectionId, + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + + #expect(token == nil) + #expect(controller.ownedActivityIds.isEmpty) + } + + @Test + func nothingIsRequestedWhileLiveActivitiesAreTurnedOff() async { + let store = SpyLiveActivityStore() + store.areActivitiesEnabled = false + let controller = makeController(store: store) + + _ = await controller.start( + connectionId: UUID(), + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + + #expect(store.requested.isEmpty) + } + + @Test + func aDisabledStoreStillReapsWhatAnEarlierProcessLeftBehind() async { + let store = SpyLiveActivityStore() + store.areActivitiesEnabled = false + let orphan = SpyLiveActivityHandle(id: "orphan", state: .init(startedAt: referenceNow)) + store.adopt([orphan]) + let controller = makeController(store: store) + + _ = await controller.start( + connectionId: UUID(), + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + + #expect(orphan.endedStates.count == 1) + } +} + +@Suite +struct QueryActivityContentStateDecodingTests { + @Test + func aStateEncodedBeforeTheOutcomeFieldExistedStillDecodes() throws { + let legacy = #"{"startedAt": 757400000, "rowsStreamed": 7}"# + let data = try #require(legacy.data(using: .utf8)) + + let state = try JSONDecoder().decode(QueryActivityAttributes.ContentState.self, from: data) + + #expect(state.outcome == .running) + #expect(state.rowsStreamed == 7) + #expect(state.endedAt == nil) + } + + @Test + func aLegacyEndedStateDecodesAsCompletedRatherThanRunning() throws { + let legacy = #"{"startedAt": 757400000, "endedAt": 757400012, "rowsStreamed": 3}"# + let data = try #require(legacy.data(using: .utf8)) + + let state = try JSONDecoder().decode(QueryActivityAttributes.ContentState.self, from: data) + + #expect(state.outcome == .completed) + } + + @Test + func aStaleCardReportsTheElapsedTimeItLastReachedNotTheStaleWindow() { + let startedAt = referenceNow.addingTimeInterval(-900) + let state = QueryActivityAttributes.ContentState( + startedAt: startedAt, + lastUpdatedAt: referenceNow.addingTimeInterval(-300) + ) + + #expect(state.elapsedWhenLastAlive == 600) + } + + @Test + func aLegacyStateReportsNoElapsedTimeRatherThanANegativeOne() throws { + let legacy = #"{"startedAt": 757400000, "rowsStreamed": 2}"# + let data = try #require(legacy.data(using: .utf8)) + + let state = try JSONDecoder().decode(QueryActivityAttributes.ContentState.self, from: data) + + #expect(state.lastUpdatedAt == state.startedAt) + #expect(state.elapsedWhenLastAlive == 0) + } + + @Test + func anOutcomeSurvivesARoundTrip() throws { + let original = QueryActivityAttributes.ContentState( + startedAt: referenceNow, + endedAt: referenceNow.addingTimeInterval(3), + rowsStreamed: 9, + outcome: .stopped + ) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(QueryActivityAttributes.ContentState.self, from: data) + + #expect(decoded == original) + } +} diff --git a/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift b/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift index 99a3a98979..a5876e7288 100644 --- a/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift @@ -35,6 +35,45 @@ struct QueryEditorViewModelTests { } } + @Test("stop marks the run cancelled rather than letting it settle as finished") + func stopRecordsCancellation() async { + let driver = MockDatabaseDriver() + let gate = QueryGate() + driver.beforeExecute = { await gate.wait() } + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["1"]], rowsAffected: 0, executionTime: 0)) + ] + + let vm = QueryEditorViewModel(windowCapacity: 100) + let run = Task { await vm.run(driver: driver, query: "SELECT 1") } + while !vm.isRunning { + await Task.yield() + } + vm.stop() + await gate.open() + await run.value + + #expect(vm.truncationReason != nil) + if case .truncated(let reason) = vm.phase, case .cancelled = reason { + #expect(vm.truncationMessage != nil) + } else { + Issue.record("expected truncated(.cancelled) phase, got \(vm.phase)") + } + #expect(QueryExecutionOutcome(phase: vm.phase) == .stopped) + } + + @Test("stop on an idle view model changes nothing") + func stopWhenIdleIsInert() { + let vm = QueryEditorViewModel(windowCapacity: 100) + + vm.stop() + + #expect(vm.truncationReason == nil) + if case .idle = vm.phase {} else { + Issue.record("expected idle phase, got \(vm.phase)") + } + } + @Test("run completes without truncation for a small result") func runCompletes() async { let driver = MockDatabaseDriver() @@ -94,3 +133,19 @@ struct QueryEditorViewModelTests { } } } + +private actor QueryGate { + private var continuation: CheckedContinuation? + private var isOpen = false + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { continuation = $0 } + } + + func open() { + isOpen = true + continuation?.resume() + continuation = nil + } +} diff --git a/TableProMobile/TableProMobileTests/QueryExecutionOutcomeTests.swift b/TableProMobile/TableProMobileTests/QueryExecutionOutcomeTests.swift new file mode 100644 index 0000000000..92d1c26623 --- /dev/null +++ b/TableProMobile/TableProMobileTests/QueryExecutionOutcomeTests.swift @@ -0,0 +1,54 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +@Suite +struct QueryExecutionOutcomeTests { + @Test + func aFinishedQueryIsTheOnlyPlainSuccess() { + #expect(QueryExecutionOutcome(phase: .finished) == .completed) + } + + @Test + func aStoppedQueryIsNotASuccess() { + #expect(QueryExecutionOutcome(phase: .truncated(reason: .cancelled)) == .stopped) + } + + @Test + func aMemoryTruncatedQueryIsNotASuccess() { + #expect(QueryExecutionOutcome(phase: .truncated(reason: .memoryPressure)) == .interrupted) + } + + @Test + func hittingTheRowCapStillCountsAsCompleted() { + #expect(QueryExecutionOutcome(phase: .truncated(reason: .rowCap(10_000))) == .completed) + #expect(QueryExecutionOutcome(phase: .truncated(reason: .driverLimit("server limit"))) == .completed) + } + + @Test + func clearingMidRunLeavesTheRunInterrupted() { + #expect(QueryExecutionOutcome(phase: .idle) == .interrupted) + } + + @Test + func onlyACompletedRunCarriesNoHistoryMessage() { + #expect(QueryExecutionOutcome.completed.historyMessage == nil) + #expect(QueryExecutionOutcome.failed.historyMessage == nil) + #expect(QueryExecutionOutcome.stopped.historyMessage != nil) + #expect(QueryExecutionOutcome.interrupted.historyMessage != nil) + } + + @Test + func everyOutcomeMapsOntoADistinctActivityOutcome() { + let mapped: [QueryActivityAttributes.Outcome] = [ + QueryExecutionOutcome.completed.activityOutcome, + QueryExecutionOutcome.failed.activityOutcome, + QueryExecutionOutcome.stopped.activityOutcome, + QueryExecutionOutcome.interrupted.activityOutcome, + ] + + #expect(Set(mapped).count == mapped.count) + #expect(!mapped.contains(.running)) + } +} diff --git a/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift b/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift index 43d4009e8e..b704ca1181 100644 --- a/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift +++ b/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift @@ -17,9 +17,9 @@ struct QueryLiveActivityWidget: Widget { .background(.tint.opacity(0.15), in: RoundedRectangle(cornerRadius: 7)) } DynamicIslandExpandedRegion(.trailing) { - elapsedText(context.state) + elapsedText(context.state, isStale: context.isStale) .font(.title3.monospacedDigit()) - .foregroundStyle(context.state.endedAt == nil ? .primary : .secondary) + .foregroundStyle(isLive(context) ? .primary : .secondary) } DynamicIslandExpandedRegion(.center) { Text(context.attributes.connectionName) @@ -34,21 +34,18 @@ struct QueryLiveActivityWidget: Widget { .lineLimit(1) .truncationMode(.tail) Spacer() - if context.state.rowsStreamed > 0 { - Label(rowCountText(context.state.rowsStreamed), systemImage: "list.bullet") - .font(.caption) - .labelStyle(.titleAndIcon) - .foregroundStyle(.secondary) - } + Text(statusText(context)) + .font(.caption) + .foregroundStyle(.secondary) } } } compactLeading: { Image(systemName: "terminal.fill") .foregroundStyle(.tint) } compactTrailing: { - compactStatus(state: context.state) + compactStatus(context) } minimal: { - compactStatus(state: context.state) + compactStatus(context) } .widgetURL(deepLink(connectionId: context.attributes.connectionId)) } @@ -78,13 +75,11 @@ struct QueryLiveActivityWidget: Widget { Spacer() VStack(alignment: .trailing, spacing: 2) { - elapsedText(context.state) + elapsedText(context.state, isStale: context.isStale) .font(.body.monospacedDigit()) - if context.state.rowsStreamed > 0 { - Text(rowCountText(context.state.rowsStreamed)) - .font(.caption2) - .foregroundStyle(.secondary) - } + Text(statusText(context)) + .font(.caption2) + .foregroundStyle(.secondary) } } .padding(.horizontal, 14) @@ -94,23 +89,76 @@ struct QueryLiveActivityWidget: Widget { // MARK: - Compact / Minimal Status @ViewBuilder - private func compactStatus(state: QueryActivityAttributes.ContentState) -> some View { - if state.endedAt != nil { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - } else { + private func compactStatus(_ context: ActivityViewContext) -> some View { + if isLive(context) { ProgressView() .progressViewStyle(.circular) .controlSize(.mini) + } else { + Image(systemName: symbolName(for: outcome(context))) + .foregroundStyle(tint(for: outcome(context))) } } // MARK: - Helpers + private func isLive(_ context: ActivityViewContext) -> Bool { + context.state.endedAt == nil && !context.isStale + } + + private func outcome(_ context: ActivityViewContext) -> QueryActivityAttributes.Outcome { + guard context.state.outcome == .running else { return context.state.outcome } + return context.isStale ? .interrupted : .running + } + + private func symbolName(for outcome: QueryActivityAttributes.Outcome) -> String { + switch outcome { + case .running: "hourglass" + case .completed: "checkmark.circle.fill" + case .failed: "xmark.circle.fill" + case .stopped: "stop.circle.fill" + case .interrupted: "exclamationmark.triangle.fill" + } + } + + private func tint(for outcome: QueryActivityAttributes.Outcome) -> Color { + switch outcome { + case .running: .secondary + case .completed: .green + case .failed: .red + case .stopped: .secondary + case .interrupted: .orange + } + } + + private func statusText(_ context: ActivityViewContext) -> String { + switch outcome(context) { + case .running: + return context.state.rowsStreamed > 0 + ? rowCountText(context.state.rowsStreamed) + : String(localized: "Running") + case .completed: + return context.state.rowsStreamed > 0 + ? rowCountText(context.state.rowsStreamed) + : String(localized: "Done") + case .failed: + return String(localized: "Failed") + case .stopped: + return String(localized: "Stopped") + case .interrupted: + return String(localized: "Interrupted") + } + } + @ViewBuilder - private func elapsedText(_ state: QueryActivityAttributes.ContentState) -> some View { + private func elapsedText( + _ state: QueryActivityAttributes.ContentState, + isStale: Bool + ) -> some View { if let ended = state.endedAt { Text(formatElapsed(ended.timeIntervalSince(state.startedAt))) + } else if isStale { + Text(formatElapsed(state.elapsedWhenLastAlive)) } else { Text(timerInterval: state.startedAt...Date.distantFuture, countsDown: false, showsHours: false) } diff --git a/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift b/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift index d6e8c7d21d..0f6da58893 100644 --- a/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift +++ b/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift @@ -1,11 +1,63 @@ import ActivityKit import Foundation +enum QueryActivityStaleWindow { + static let seconds: TimeInterval = 5 * 60 +} + nonisolated struct QueryActivityAttributes: ActivityAttributes { - public struct ContentState: Codable, Hashable { + enum Outcome: String, Codable, Hashable, Sendable { + case running + case completed + case failed + case stopped + case interrupted + } + + struct ContentState: Codable, Hashable { var startedAt: Date + var lastUpdatedAt: Date var endedAt: Date? var rowsStreamed: Int + var outcome: Outcome + + init( + startedAt: Date, + lastUpdatedAt: Date? = nil, + endedAt: Date? = nil, + rowsStreamed: Int = 0, + outcome: Outcome = .running + ) { + self.startedAt = startedAt + self.lastUpdatedAt = lastUpdatedAt ?? startedAt + self.endedAt = endedAt + self.rowsStreamed = rowsStreamed + self.outcome = outcome + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + startedAt = try container.decode(Date.self, forKey: .startedAt) + lastUpdatedAt = try container.decodeIfPresent(Date.self, forKey: .lastUpdatedAt) ?? startedAt + endedAt = try container.decodeIfPresent(Date.self, forKey: .endedAt) + rowsStreamed = try container.decodeIfPresent(Int.self, forKey: .rowsStreamed) ?? 0 + let stored = try container.decodeIfPresent(Outcome.self, forKey: .outcome) + outcome = stored ?? (endedAt == nil ? .running : .completed) + } + + var elapsedWhenLastAlive: TimeInterval { + max(0, lastUpdatedAt.timeIntervalSince(startedAt)) + } + + func ended(as outcome: Outcome, at endedAt: Date) -> ContentState { + ContentState( + startedAt: startedAt, + lastUpdatedAt: endedAt, + endedAt: endedAt, + rowsStreamed: rowsStreamed, + outcome: outcome + ) + } } let connectionId: UUID diff --git a/docs/ios/index.mdx b/docs/ios/index.mdx index b0ace05e7c..fe5c585d80 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -64,7 +64,9 @@ A new row starts with every column on **DEFAULT**, which leaves that column out ### Querying -The editor highlights SQL, runs a statement, and stops one mid-flight. Results copy or export as JSON, CSV, or SQL `INSERT`. A running query appears in a Live Activity on the lock screen and Dynamic Island; **Settings > Privacy** hides the SQL text there. +The editor highlights SQL, runs a statement, and stops one mid-flight. **Stop** ends the result stream at once; on MySQL and Redis the statement keeps running on the server until it finishes. Results copy or export as JSON, CSV, or SQL `INSERT`. + +A running query appears in a Live Activity on the lock screen and Dynamic Island, with the elapsed time and the row count so far. **Settings > Privacy** replaces the SQL text there with "Running query". The card clears when the query ends. Quitting the app mid-query leaves it behind, marked interrupted, until the next launch clears it. ## What is missing