Repository: Tyler-Keith-Thompson/Afluent
Pull request #178: Implement async sequence output at
Author: login: iThinker
Created: 2025-03-08T01:12:28Z | Merged: 2025-03-10T01:52:38Z

Feature parity with Combine - https://developer.apple.com/documentation/combine/publisher/output(at:)

1. Implement `output(at:)` similar to `collect`.
2. Document.
3. Test.
4. Extract some repeated test helpers to avoid copying them.


Diff:
diff --git a/Sources/Afluent/SequenceOperators/OutputSequence.swift b/Sources/Afluent/SequenceOperators/OutputSequence.swift
new file mode 100644
index 000000000..261a845bb
--- /dev/null
+++ b/Sources/Afluent/SequenceOperators/OutputSequence.swift
@@ -0,0 +1,51 @@
+//
+//  OutputSequence.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-05.
+//
+
+import Foundation
+
+extension AsyncSequences {
+    public struct OutputAt<Upstream: AsyncSequence & Sendable>: AsyncSequence, Sendable {
+        public typealias Element = Upstream.Element
+        let upstream: Upstream
+        let index: Int
+
+        public struct AsyncIterator: AsyncIteratorProtocol {
+            var upstreamIterator: Upstream.AsyncIterator
+            let index: Int
+            var nextIndex = 0
+
+            public mutating func next() async throws -> Element? {
+                guard nextIndex <= index else { return nil }
+                while let next = try await upstreamIterator.next() {
+                    if nextIndex == index {
+                        nextIndex &+= 1
+                        return next
+                    }
+                    nextIndex &+= 1
+                }
+                return nil
+            }
+        }
+
+        public func makeAsyncIterator() -> AsyncIterator {
+            AsyncIterator(upstreamIterator: upstream.makeAsyncIterator(), index: index)
+        }
+    }
+    
+}
+
+extension AsyncSequence where Self: Sendable {
+    
+    /// Returns a sequence containing a specific indexed element.
+    /// If the sequence finishes normally or with an error before emitting the specified element, then the sequence doesn’t produce any elements.
+    /// - Parameter index: The index that indicates the element needed.
+    /// - Returns: A sequence containing a specific indexed element.
+    public func output(at index: Int) -> AsyncSequences.OutputAt<Self> {
+        AsyncSequences.OutputAt(upstream: self, index: index)
+    }
+    
+}
diff --git a/Tests/AfluentTests/SequenceTests/CollectSequenceTests.swift b/Tests/AfluentTests/SequenceTests/CollectSequenceTests.swift
index b1fad2568..1a77a1743 100644
--- a/Tests/AfluentTests/SequenceTests/CollectSequenceTests.swift
+++ b/Tests/AfluentTests/SequenceTests/CollectSequenceTests.swift
@@ -37,29 +37,11 @@ struct CollectSequenceTests {
     }
 
     @Test func testCollectWithSequenceThrowingError() async throws {
-        enum TestError: Error, Equatable {
-            case someError
-        }
-
         let errorSequence = AsyncThrowingStream<Int, Error> { continuation in
-            continuation.finish(throwing: TestError.someError)
+            continuation.finish(throwing: GeneralError.e1)
         }
-        do {
+        await #expect(throws: GeneralError.e1, performing: {
             _ = try await errorSequence.collect().first()
-            Issue.record("Collect should throw the error encountered in the sequence")
-        } catch {
-            #expect(error as? TestError == .someError, "Collect should throw the correct error")
-        }
-    }
-}
-
-extension Array where Element: Sendable {
-    fileprivate var async: AsyncStream<Element> {
-        AsyncStream { continuation in
-            for element in self {
-                continuation.yield(element)
-            }
-            continuation.finish()
-        }
+        })
     }
 }
diff --git a/Tests/AfluentTests/SequenceTests/DelaySequenceTests.swift b/Tests/AfluentTests/SequenceTests/DelaySequenceTests.swift
index 6fb7121a9..3a5973cd4 100644
--- a/Tests/AfluentTests/SequenceTests/DelaySequenceTests.swift
+++ b/Tests/AfluentTests/SequenceTests/DelaySequenceTests.swift
@@ -130,14 +130,3 @@ struct DelaySequenceTests {
         }
     }
 }
-
-extension Array where Element: Sendable {
-    fileprivate var async: AsyncStream<Element> {
-        AsyncStream { continuation in
-            for element in self {
-                continuation.yield(element)
-            }
-            continuation.finish()
-        }
-    }
-}
diff --git a/Tests/AfluentTests/SequenceTests/HandleEventsSequenceTests.swift b/Tests/AfluentTests/SequenceTests/HandleEventsSequenceTests.swift
index 48aab614b..170786c24 100644
--- a/Tests/AfluentTests/SequenceTests/HandleEventsSequenceTests.swift
+++ b/Tests/AfluentTests/SequenceTests/HandleEventsSequenceTests.swift
@@ -159,14 +159,3 @@ struct HandleEventsSequenceTests {
         }
     }
 }
-
-extension Array where Element: Sendable {
-    fileprivate var async: AsyncStream<Element> {
-        AsyncStream { continuation in
-            for element in self {
-                continuation.yield(element)
-            }
-            continuation.finish()
-        }
-    }
-}
diff --git a/Tests/AfluentTests/SequenceTests/OutputSequenceTests.swift b/Tests/AfluentTests/SequenceTests/OutputSequenceTests.swift
new file mode 100644
index 000000000..32a57fa96
--- /dev/null
+++ b/Tests/AfluentTests/SequenceTests/OutputSequenceTests.swift
@@ -0,0 +1,63 @@
+//
+//  OutputSequenceTests.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-07.
+//
+
+import Afluent
+import Foundation
+import Testing
+import ConcurrencyExtras
+
+struct OutputSequenceTests {
+    
+    @Test func testOutputAt() async throws {
+        let emptySequence = [0, 3, 5].async
+        let result = try await emptySequence.output(at: 1).first()
+        #expect(result == 3)
+    }
+    
+    @Test func testOutputAtWithEmptySequence() async throws {
+        let emptySequence = [Int]().async
+        let result = try await emptySequence.output(at: 0).first()
+        try #require(result == nil)
+    }
+    
+    @Test func testOutputAtOutOfBounds() async throws {
+        let emptySequence = [0, 3, 5].async
+        let result = try await emptySequence.output(at: 5).first()
+        #expect(result == nil)
+    }
+    
+    @Test func testOutputAtWithSequenceThrowingError() async throws {
+        let errorSequence = AsyncThrowingStream<Int, Error> { continuation in
+            continuation.finish(throwing: GeneralError.e1)
+        }
+        await #expect(throws: GeneralError.e1, performing: {
+            _ = try await errorSequence.output(at: 0).first()
+        })
+    }
+    
+    @Test func testOutputAtCancellation() async throws {
+        let (stream, continuation) = AsyncThrowingStream.makeStream(of: Int.self)
+        
+        let task = Task {
+            let result = try await stream.output(at: 4).first()
+            #expect(result == nil)
+            return result
+        }
+        
+        continuation.yield(0)
+        task.cancel()
+        
+        // Give task cancellation time to propagate.
+        await Task.megaYield()
+        
+        continuation.finish(throwing: GeneralError.e1)
+        
+        let result = try await task.value
+        #expect(result == nil)
+    }
+    
+}
diff --git a/Tests/AfluentTests/SequenceTests/ShareSequenceTests.swift b/Tests/AfluentTests/SequenceTests/ShareSequenceTests.swift
index d2487302b..d7520fd03 100644
--- a/Tests/AfluentTests/SequenceTests/ShareSequenceTests.swift
+++ b/Tests/AfluentTests/SequenceTests/ShareSequenceTests.swift
@@ -326,17 +326,6 @@ func throwOn<T: Equatable>(_ toThrowOn: T, _ value: T) throws -> T {
     return value
 }
 
-extension Array where Element: Sendable {
-    fileprivate var async: AsyncStream<Element> {
-        AsyncStream<Element> {
-            for item in self {
-                $0.yield(item)
-            }
-            $0.finish()
-        }
-    }
-}
-
 extension RangeReplaceableCollection {
     fileprivate init<Source: AsyncSequence>(_ source: Source) async rethrows
     where Source.Element == Element {
diff --git a/Tests/AfluentTests/TestHelpers/ArrayExtensions.swift b/Tests/AfluentTests/TestHelpers/ArrayExtensions.swift
new file mode 100644
index 000000000..c6b5c0518
--- /dev/null
+++ b/Tests/AfluentTests/TestHelpers/ArrayExtensions.swift
@@ -0,0 +1,19 @@
+//
+//  ArrayExtensions.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-07.
+//
+
+import Foundation
+
+extension Array where Element: Sendable {
+    var async: AsyncStream<Element> {
+        AsyncStream<Element> {
+            for item in self {
+                $0.yield(item)
+            }
+            $0.finish()
+        }
+    }
+}
diff --git a/Tests/AfluentTests/GeneralError.swift b/Tests/AfluentTests/TestHelpers/GeneralError.swift
similarity index 100%
rename from Tests/AfluentTests/GeneralError.swift
rename to Tests/AfluentTests/TestHelpers/GeneralError.swift


Reviews:
user: login: Tyler-Keith-Thompson
body: Thank you for submitting this PR! I left a couple of small comments but this looks good!
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-08T15:25:27Z
user: login: iThinker
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T20:43:34Z
user: login: iThinker
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T20:46:21Z
user: login: iThinker
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T20:55:02Z
user: login: iThinker
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T20:58:18Z
user: login: Tyler-Keith-Thompson
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-08T21:06:14Z
user: login: iThinker
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T22:28:17Z
user: login: iThinker
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T22:28:49Z
user: login: Tyler-Keith-Thompson
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-08T23:29:23Z
user: login: iThinker
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T05:01:28Z
user: login: Tyler-Keith-Thompson
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-09T18:54:08Z
user: login: Tyler-Keith-Thompson
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-09T18:58:54Z
user: login: iThinker
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T22:32:58Z
user: login: Tyler-Keith-Thompson
state: APPROVED
author_association: OWNER
submitted_at: 2025-03-10T01:48:51Z

Review comments:
diff_hunk: @@ -0,0 +1,55 @@
+//
+//  OutputSequence.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-05.
+//
+
+import Foundation
+
+extension AsyncSequences {
+    public struct OutputAt<Upstream: AsyncSequence & Sendable>: AsyncSequence, Sendable {
+        public typealias Element = Upstream.Element
+        let upstream: Upstream
+        let index: Int
+
+        public struct AsyncIterator: AsyncIteratorProtocol {
+            var upstreamIterator: Upstream.AsyncIterator
+            let index: Int
+            var finished = false
path: Sources/Afluent/SequenceOperators/OutputSequence.swift
user: login: Tyler-Keith-Thompson
body: What's the purpose of this variable? I commented it out and ran the tests and they still passed. 

If it's about this runtime contract: https://developer.apple.com/documentation/swift/asynciteratorprotocol#End-of-Iteration we have a couple choices I think:
- Just rely on the upstream to do this (no need to track state in this operator)
- Don't rely on the upstream

In either case, we should probably write a test for it.
created_at: 2025-03-08T15:21:38Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,12 @@
+//
+//  TestError.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-07.
+//
+
+import Foundation
+
+enum TestError: Error, Equatable {
path: Tests/AfluentTests/TestHelpers/TestError.swift
user: login: Tyler-Keith-Thompson
body: This particular error might not make a great candidate for extraction. It's kind of test specific, sometimes it needs to be equatable, sometimes it might need to conform to other protocols, or have other cases, or associated types, etc.
created_at: 2025-03-08T15:22:45Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,19 @@
+//
+//  ArrayExtensions.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-07.
+//
+
+import Foundation
+
+extension Array where Element: Sendable {
path: Tests/AfluentTests/TestHelpers/ArrayExtensions.swift
user: login: Tyler-Keith-Thompson
body: Thanks for extracting this, I do think it's a good candidate.
created_at: 2025-03-08T15:23:14Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,41 @@
+//
+//  OutputSequenceTests.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-07.
+//
+
+import Afluent
+import Foundation
+import Testing
+
+struct OutputSequenceTests {
path: Tests/AfluentTests/SequenceTests/OutputSequenceTests.swift
user: login: Tyler-Keith-Thompson
body: We should probably write an additional test case for cancellation, looks like there's 2 places we check for it.
created_at: 2025-03-08T15:23:47Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,55 @@
+//
+//  OutputSequence.swift
path: Sources/Afluent/SequenceOperators/OutputSequence.swift
user: login: Tyler-Keith-Thompson
body: Nitpick: Should we call this file `OutputAtSequence`?
created_at: 2025-03-08T15:25:13Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,55 @@
+//
+//  OutputSequence.swift
path: Sources/Afluent/SequenceOperators/OutputSequence.swift
user: login: iThinker
body: I plan to add `output(in:)` here as well. Do you think they should be in different files?
created_at: 2025-03-08T20:43:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,41 @@
+//
+//  OutputSequenceTests.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-07.
+//
+
+import Afluent
+import Foundation
+import Testing
+
+struct OutputSequenceTests {
path: Tests/AfluentTests/SequenceTests/OutputSequenceTests.swift
user: login: iThinker
body: Yeah makes sense.
created_at: 2025-03-08T20:46:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,12 @@
+//
+//  TestError.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-07.
+//
+
+import Foundation
+
+enum TestError: Error, Equatable {
path: Tests/AfluentTests/TestHelpers/TestError.swift
user: login: iThinker
body: I found `GeneralError` which serves the same purpose. I will move it to helpers to be more discoverable.
created_at: 2025-03-08T20:55:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,55 @@
+//
+//  OutputSequence.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-05.
+//
+
+import Foundation
+
+extension AsyncSequences {
+    public struct OutputAt<Upstream: AsyncSequence & Sendable>: AsyncSequence, Sendable {
+        public typealias Element = Upstream.Element
+        let upstream: Upstream
+        let index: Int
+
+        public struct AsyncIterator: AsyncIteratorProtocol {
+            var upstreamIterator: Upstream.AsyncIterator
+            let index: Int
+            var finished = false
path: Sources/Afluent/SequenceOperators/OutputSequence.swift
user: login: iThinker
body: I followed `collect` implementation.
But I likely don't need it and can simply check for `currentIndex` being larger than `index`.
created_at: 2025-03-08T20:58:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,55 @@
+//
+//  OutputSequence.swift
path: Sources/Afluent/SequenceOperators/OutputSequence.swift
user: login: Tyler-Keith-Thompson
body: Gotcha, let's leave it for now and see how big it gets with the other operator. 
created_at: 2025-03-08T21:06:14Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,41 @@
+//
+//  OutputSequenceTests.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-07.
+//
+
+import Afluent
+import Foundation
+import Testing
+
+struct OutputSequenceTests {
path: Tests/AfluentTests/SequenceTests/OutputSequenceTests.swift
user: login: iThinker
body: I am looking at Apple sequences and they never check for cancellation. For example https://github.com/swiftlang/swift/blob/main/stdlib/public/Concurrency/AsyncPrefixSequence.swift .
`output(at:)` is similar to prefix only it drops first X-1 elements.
Should we follow Apple and skip checking for cancellation altogether?
created_at: 2025-03-08T22:28:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,41 @@
+//
+//  OutputSequenceTests.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-07.
+//
+
+import Afluent
+import Foundation
+import Testing
+
+struct OutputSequenceTests {
path: Tests/AfluentTests/SequenceTests/OutputSequenceTests.swift
user: login: iThinker
body: In the mean time I am trying to research what would be the expected behaviour and how to model it in tests.
created_at: 2025-03-08T22:28:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,41 @@
+//
+//  OutputSequenceTests.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-07.
+//
+
+import Afluent
+import Foundation
+import Testing
+
+struct OutputSequenceTests {
path: Tests/AfluentTests/SequenceTests/OutputSequenceTests.swift
user: login: Tyler-Keith-Thompson
body: Wow, interesting. We have quite a few cancellation tests. I might need to look into this more. It seems like all things Apple should be cooperative for cancellation, right?
created_at: 2025-03-08T23:29:23Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,41 @@
+//
+//  OutputSequenceTests.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-07.
+//
+
+import Afluent
+import Foundation
+import Testing
+
+struct OutputSequenceTests {
path: Tests/AfluentTests/SequenceTests/OutputSequenceTests.swift
user: login: iThinker
body: I went down the rabbit hole and manually tested different scenarios of cancellation with prints. Somehow removing task cancellation checks worked totally fine. I will push a test case showcasing it soon.
created_at: 2025-03-09T05:01:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,41 @@
+//
+//  OutputSequenceTests.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-07.
+//
+
+import Afluent
+import Foundation
+import Testing
+
+struct OutputSequenceTests {
path: Tests/AfluentTests/SequenceTests/OutputSequenceTests.swift
user: login: Tyler-Keith-Thompson
body: For visibility: We did a fair bit of back-and-forth on Slack and we've discovered several things:

- The assumption for AsyncSequence seems to be that "base" sequences (ones that produce their own output, rather than operating on output from upstream sequences) should check for cancellation. Sequences like this one do *not* explicitly check cancellation across Apple libraries
- Ideally Afluent should be using [withTaskCancellationHandler](https://developer.apple.com/documentation/swift/withtaskcancellationhandler(handler:operation:)) as it's more performant. There are apparently some issues with `try Task.checkCancellation()`'s performance. 

Next steps:
- This PR does not need to add explicit cancellation checks nor does it need to have tests for cancellation
- A new PR needs to be created with more details of these findings that updates all superfluous checks in Afluent and updates the CONTRIBUTING.MD file to explain when, where, how, and why one should check for cancellation. (I'll open this soon)
created_at: 2025-03-09T18:54:08Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,54 @@
+//
+//  OutputSequence.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-05.
+//
+
+import Foundation
+
+extension AsyncSequences {
+    public struct OutputAt<Upstream: AsyncSequence & Sendable>: AsyncSequence, Sendable {
+        public typealias Element = Upstream.Element
+        let upstream: Upstream
+        let index: Int
+
+        public struct AsyncIterator: AsyncIteratorProtocol {
+            var upstreamIterator: Upstream.AsyncIterator
+            let index: Int
+            var nextIndex = 0
+
+            public mutating func next() async throws -> Element? {
+//                try Task.checkCancellation()
+                guard nextIndex <= index else { return nil }
+                while let next = try await upstreamIterator.next() {
+                    Swift.print("iter \(nextIndex)")
+//                    try Task.checkCancellation()
+                    if nextIndex == index {
+                        nextIndex &+= 1
path: Sources/Afluent/SequenceOperators/OutputSequence.swift
user: login: Tyler-Keith-Thompson
body: Good idea on the overflow protection
created_at: 2025-03-09T18:57:41Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,54 @@
+//
+//  OutputSequence.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-05.
+//
+
+import Foundation
+
+extension AsyncSequences {
+    public struct OutputAt<Upstream: AsyncSequence & Sendable>: AsyncSequence, Sendable {
+        public typealias Element = Upstream.Element
+        let upstream: Upstream
+        let index: Int
+
+        public struct AsyncIterator: AsyncIteratorProtocol {
+            var upstreamIterator: Upstream.AsyncIterator
+            let index: Int
+            var nextIndex = 0
+
+            public mutating func next() async throws -> Element? {
+//                try Task.checkCancellation()
+                guard nextIndex <= index else { return nil }
+                while let next = try await upstreamIterator.next() {
+                    Swift.print("iter \(nextIndex)")
+//                    try Task.checkCancellation()
path: Sources/Afluent/SequenceOperators/OutputSequence.swift
user: login: Tyler-Keith-Thompson
body: I know you're working on it. I'm leaving this as a reminder in GitHub before I click "merge" that these commented lines should be removed.
created_at: 2025-03-09T18:58:10Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,55 @@
+//
+//  OutputSequence.swift
+//  Afluent
+//
+//  Created by Roman Temchenko on 2025-03-05.
+//
+
+import Foundation
+
+extension AsyncSequences {
+    public struct OutputAt<Upstream: AsyncSequence & Sendable>: AsyncSequence, Sendable {
+        public typealias Element = Upstream.Element
+        let upstream: Upstream
+        let index: Int
+
+        public struct AsyncIterator: AsyncIteratorProtocol {
+            var upstreamIterator: Upstream.AsyncIterator
+            let index: Int
+            var finished = false
path: Sources/Afluent/SequenceOperators/OutputSequence.swift
user: login: iThinker
body: Removed `finished` var.
created_at: 2025-03-09T22:32:58Z
author_association: CONTRIBUTOR

PR comments:
user: login: trunk-io[bot]
created_at: 2025-03-08T01:12:32Z
body: 😎 Merged successfully - [details](https://app.trunk.io/gh/Tyler-Keith-Thompson/Afluent/merge-queue/main/178).
author_association: NONE
user: login: iThinker
created_at: 2025-03-08T01:13:13Z
body: PTAL @Tyler-Keith-Thompson 
author_association: CONTRIBUTOR