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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
# DevFoundation Changelog


## 1.6.0: October 24, 2025

This release introduces the `LiveQuery` subsystem, a set of types for managing search-as-you-type
functionality and other query-based operations. Live queries automatically handle scheduling,
deduplication, and caching as query fragments change.

- `LiveQuery` is an `Observable` type that produces results as its query fragment changes. It
coordinates between user input and result production, managing debouncing, duplicate removal,
and error handling.
- `LiveQueryResultsProducer` is a protocol that defines how to generate results for query
fragments. Conforming types specify their scheduling strategy and implement result production
logic.
- `LiveQuerySchedulingStrategy` determines when results are generated: `.passthrough` for
immediate results (best for cheap operations like local filtering), or `.debounce(_:)` to wait
for typing to pause (best for expensive operations like network requests).

The live query subsystem is fully thread-safe, `Sendable`, and integrates seamlessly with SwiftUI
through the Observation framework.


## 1.5.0: October 22, 2025

This release adds the `UserSelection` type, a generic structure that manages a user’s selection with
Expand Down
20 changes: 19 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,18 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.6.1"),
.package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.0.4"),
.package(url: "https://github.com/apple/swift-numerics.git", from: "1.1.0"),
.package(url: "https://github.com/DevKitOrganization/DevTesting", from: "1.5.0"),
.package(url: "https://github.com/prachigauriar/URLMock.git", from: "1.3.6"),
],
targets: [
.target(
name: "DevFoundation",
swiftSettings: swiftSettings,
dependencies: [
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms")
],
swiftSettings: swiftSettings
),
.testTarget(
name: "DevFoundationTests",
Expand Down
10 changes: 10 additions & 0 deletions Sources/DevFoundation/Documentation.docc/Documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ for paging through data, and essential utility types for building robust applica

- <doc:PagingThroughData>

### Live Queries

- ``LiveQuery``
- ``LiveQueryResultsProducer``
- ``LiveQuerySchedulingStrategy``

### Caching

- ``ExpiringValue``
Expand Down Expand Up @@ -57,6 +63,10 @@ for paging through data, and essential utility types for building robust applica

- ``GibberishGenerator``

### Handling User Inputs

- ``UserSelection``

### Obfuscating and Deobfuscating Data

- ``Foundation/Data``
Expand Down
208 changes: 208 additions & 0 deletions Sources/DevFoundation/Live Query/LiveQuery.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
//
// LiveQuery.swift
// DevFoundation
//
// Created by Prachi Gauriar on 10/23/2025.
//

import AsyncAlgorithms
import Foundation
import Observation
import Synchronization

/// An observable query that produces results as its fragment changes.
///
/// Live queries manage the lifecycle of producing results as users type. Set the ``queryFragment`` property and observe
/// ``results`` for updates. The query automatically handles scheduling, deduplication, and caching.
///
///
/// ## Overview
///
/// `LiveQuery` provides a reactive interface for search-as-you-type functionality. It coordinates between user input
/// and result production, handling common concerns like debouncing, duplicate removal, and error management.
///
///
/// ## Usage
///
/// Create a live query by providing a ``LiveQueryResultsProducer`` that defines how to generate results:
///
/// struct SearchProducer: LiveQueryResultsProducer {
/// var schedulingStrategy: LiveQuerySchedulingStrategy {
/// .debounce(.milliseconds(300))
/// }
///
/// func results(forQueryFragment queryFragment: String) async throws -> [SearchResult] {
/// // Perform search and return results
/// }
/// }
///
/// let liveQuery = LiveQuery(resultsProducer: SearchProducer())
///
/// Update the query fragment to trigger result production:
///
/// liveQuery.queryFragment = "search term"
///
/// Observe results using SwiftUI or the Observation framework:
///
/// @State var liveQuery = LiveQuery(resultsProducer: SearchProducer())
///
/// var body: some View {
/// List(liveQuery.results ?? []) { result in
/// Text(result.title)
/// }
/// .searchable(text: $liveQuery.queryFragment)
/// }
///
///
/// ## Scheduling Strategies
///
/// The results producer’s ``LiveQueryResultsProducer/schedulingStrategy`` determines when results are generated:
///
/// - ``LiveQuerySchedulingStrategy/passthrough``: Produces results immediately for every change. Best for cheap
/// operations like filtering local data.
/// - ``LiveQuerySchedulingStrategy/debounce(_:)``: Waits for typing to pause before producing results. Best for
/// expensive operations like network requests.
///
///
/// ## Error Handling
///
/// Errors from result production are captured in ``lastError``. The query continues operating after errors,
/// preserving the last successful results until new ones are produced.
///
///
/// ## Thread Safety
///
/// `LiveQuery` is fully thread-safe and `Sendable`. All properties can be accessed from any thread, though
/// observation notifications follow the standard Observation framework behavior.
///
///
/// ## Performance
///
/// The query automatically deduplicates identical fragments and canonicalizes input through the results producer.
/// This prevents unnecessary work when users make redundant changes.
@Observable
public final class LiveQuery<Results>: Sendable where Results: Sendable {
/// The query’s mutable state.
private struct State {
/// The current query fragment.
var queryFragment: String = ""

/// The latest results.
var results: Results?

/// The last error that occurred.
var lastError: (any Error)?

/// The task that handles inputs.
var inputHandlingTask: Task<Void, Never>?
}

/// A mutex that synchronizes access to the query’s mutable state.
private let stateMutex = Mutex(State())

/// A continuation used to yield query fragment changes to the input handling task.
private let queryFragmentContinuation: AsyncStream<String>.Continuation

/// The results producer used to generate results.
private let resultsProducer: any LiveQueryResultsProducer<Results>


/// Creates a live query with the specified results producer.
///
/// - Parameter resultsProducer: The producer used to generate results.
public init(resultsProducer: some LiveQueryResultsProducer<Results>) {
self.resultsProducer = resultsProducer

let (queryFragmentStream, queryFragmentContinuation) = AsyncStream<String>.makeStream()
self.queryFragmentContinuation = queryFragmentContinuation

let queryFragmentValues = queryFragmentStream.schedule(with: resultsProducer.schedulingStrategy)
let task = Task { [weak self] in
for await queryFragment in queryFragmentValues {
guard let self else {
return
}

do {
let results = try await resultsProducer.results(forQueryFragment: queryFragment)

_$observationRegistrar.willSet(self, keyPath: \.lastError)
_$observationRegistrar.willSet(self, keyPath: \.results)
stateMutex.withLock { state in
state.results = results
state.lastError = nil
}
_$observationRegistrar.didSet(self, keyPath: \.results)
_$observationRegistrar.didSet(self, keyPath: \.lastError)
} catch {
withMutation(keyPath: \.lastError) {
stateMutex.withLock { $0.lastError = error }
}
}
}
}

stateMutex.withLock { $0.inputHandlingTask = task }
}


deinit {
stateMutex.withLock { $0.inputHandlingTask?.cancel() }
queryFragmentContinuation.finish()
}


/// The current query fragment.
///
/// Updates trigger result production based on the scheduling strategy.
public var queryFragment: String {
get {
access(keyPath: \.queryFragment)
return stateMutex.withLock(\.queryFragment)
}

set {
withMutation(keyPath: \.queryFragment) {
stateMutex.withLock { $0.queryFragment = newValue }
}

if let canonicalQueryFragment = resultsProducer.canonicalQueryFragment(from: newValue) {
queryFragmentContinuation.yield(canonicalQueryFragment)
}
}
}


/// The most recent query results, or `nil` if none have been produced yet.
public var results: Results? {
access(keyPath: \.results)
return stateMutex.withLock(\.results)
}


/// The error that occurred the last time we produced results, or `nil` if none occurred.
public var lastError: (any Error)? {
access(keyPath: \.lastError)
return stateMutex.withLock(\.lastError)
}
}


extension AsyncSequence where Self: Sendable, Element: Equatable & Sendable {
/// Applies modifiers to the input stream so that it delivers values according to the strategy.
///
/// - Parameter inputSequence: The input sequence to modify.
/// - Returns: A sequence with duplicates removed and the appropriate timing strategy applied.
fileprivate func schedule(
with strategy: LiveQuerySchedulingStrategy
) -> any AsyncSequence<Element, Failure> {
let deduplicated = removeDuplicates()

switch strategy.strategy {
case .passthrough:
return deduplicated
case .debounce(let duration):
return deduplicated.debounce(for: duration)
}
}
}
46 changes: 46 additions & 0 deletions Sources/DevFoundation/Live Query/LiveQueryResultsProducer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// LiveQueryResultsProducer.swift
// DevFoundation
//
// Created by Prachi Gauriar on 10/23/2025.
//

import Foundation

/// A type that produces results for query fragments.
///
/// Conform to this protocol to define how results are generated or fetched for a given query fragment. ``LiveQuery``
/// uses result producers to fetch results and determine how to schedule result production.
public protocol LiveQueryResultsProducer<Results>: Sendable {
/// The type of results that instances produce.
associatedtype Results: Sendable

/// The strategy used to schedule result production.
var schedulingStrategy: LiveQuerySchedulingStrategy { get }

/// Returns a canonical form of the query fragment, or `nil` if it’s invalid.
///
/// Conforming types can use this function to sanitize or validate query fragments before producing results. The
/// default implementation trims whitespace, collapses multiple spaces into one, and returns `nil` for empty
/// strings.
///
/// - Parameter queryFragment: The query fragment to canonicalize.
func canonicalQueryFragment(from queryFragment: String) -> String?

/// Produces results for the specified query fragment.
///
/// - Parameter queryFragment: A canonical query fragment. Live queries ensure this is already canonicalized before
/// calling this function.
func results(forQueryFragment queryFragment: String) async throws -> Results
}


extension LiveQueryResultsProducer {
public func canonicalQueryFragment(from queryFragment: String) -> String? {
let canonicalFragment = queryFragment.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: " ")

return canonicalFragment.isEmpty ? nil : canonicalFragment
}
}
Loading