feat(store): futures::Stream bridge for owning-output lending streams#171
Merged
Conversation
Adds a feature-gated bridge from `EventStream` (the GAT lending cursor) into `futures_core::Stream` for paths that want the futures ecosystem's combinator surface. PR2 of the stream refactor. - New `futures-bridge` feature gates an optional `futures-core` dep. - New `OwnedEventStream<M>` sub-trait witnesses "Item<'a> is an owned value" via a concrete `OwnedItem` associated type. Mirrors PR1's `BaseEventStream` pattern, which sidesteps the HRTB-on-GAT type equality issue on stable Rust. - New `IntoStream<S, M>` implements `futures_core::Stream` via a hand-rolled state machine (Idle / Pending / Done) that box-pins each `next()` future. The unfold pattern moves the cursor into the future and returns it back, sidestepping self-referential lifetimes; the box-pin is the per-event allocation the plan calls out as explicit and opt-in. - `EventStreamExt::into_stream()` requires `OwnedEventStream<M>`, which is implemented for `Map` and `TryMap` (but NOT for raw cursors whose `Item<'a>` borrows from the cursor). Users must chain `.map(...)` / `.try_map(...)` first so the per-event materialization is visible at the call site. - Hakari: `futures-core` excluded from workspace-hack unification so the "feature off → dep absent from dep tree" guarantee holds workspace-wide. Tests cover sequence/protocol (try_map → into_stream → try_collect), lifecycle (empty stream, fused-after-exhaustion), defensive boundary (closure error, underlying-stream error), and Send/spawn. `Filter` (deferred from PR1) is unaffected and remains a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 tasks
joeldsouzax
added a commit
that referenced
this pull request
May 27, 2026
…am — PR4 of arc-subscription refactor (#180) PR4 was framed in the plan as "evaluate whether `OwnedEventStream` and `IntoStream` simplify or delete now that subscription cursors are Arc-based and 'static." The evaluation came back: no, they don't. Cursor structs became 'static, but `EventStream::Item<'a>` is still a GAT — `Item<'a>` is still a borrowing `PersistedEnvelope` on the base cursors. The witness that `OwnedEventStream` carries ("this combinator's `Item<'a>` is, in fact, owned `T: 'static` — here is the name of the type") cannot be expressed via a `for<'a> Item<'a>: 'static` HRTB on stable Rust. The sub-trait survives PR1–PR3 structurally unchanged. What was missing was the design intent — *why* the bridge exists at all, and *why* every owning-combinator impl matters even when no in-tree caller exercises a particular chain. Without that intent recorded, the natural reading invites YAGNI-style deletion of impls whose chains are not yet exercised. This commit records the intent so future readers (and reviewers) understand the shape: - Module-level doc gains a "Why the bridge exists" section explaining the on-ramp pattern: implement small owning combinators in the GAT world (`Map`, `TryMap`, `MapErr`), then cross into `futures::Stream` via `.into_stream()` to ride the full futures combinator ecosystem (`.take_while`, `.filter`, `.chain`, `.then`, `.buffer_unordered`, `try_collect`, ...). - Module-level doc gains a "Trait surface IS the contract" section stating that each `OwnedEventStream` impl is one lane on the on-ramp and that an impl whose chain has no in-tree consumer is still load-bearing API — it defines what users *can* express via `.into_stream()`. - The "Implemented for [`Map`] and [`TryMap`]" bullet is updated to mention `MapErr`, which has been an impl in this module since PR #171 (the doc fell out of sync at that time). - `IntoStream`'s `_marker: PhantomData<fn() -> M>` field gains an inline doc-comment explaining why it is structurally needed (`M` appears only in the trait bound `S: OwnedEventStream<M>`, not in any field). No source code changes. No trait shape changes. No impl additions or removals. PR4 ships as docs-only. See `docs/plans/2026-05-26-arc-subscription-deviations.md` under "[PR 4 | Task 1] — Decision REVISED" for the full evaluation history, including the controller's initial wrong call (Option B with `MapErr` impl deletion on YAGNI grounds) and why it was reversed. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PR2 of the dual-track stream refactor (plan:
cheerful-yawning-hopper). Adds a feature-gated bridge from the GAT lendingEventStreamintofutures_core::Streamso consumers can opt in to the futures ecosystem when they want owning output.futures-bridgefeature gates an optionalfutures-coredep.OwnedEventStream<M>sub-trait witnesses "Item<'a> is an owned value" via a concreteOwnedItemassociated type. Mirrors PR1'sBaseEventStreampattern, which sidesteps the HRTB-on-GAT type equality issue on stable Rust. Implemented forMapandTryMaponly — not for raw cursors (theirItem<'a>borrows from the cursor). To bridge a raw cursor, chain.map(...)/.try_map(...)first so the per-event materialization is named at the call site.IntoStream<S, M>implementsfutures_core::Streamvia a hand-rolled state machine (Idle / Pending / Done) that box-pins eachnext()future. The unfold pattern moves the cursor into the future and returns it back — sidestepping self-referential lifetimes. The box-pin is the per-event allocation the plan calls out as explicit and opt-in.EventStreamExt::into_stream()is the entry point (also feature-gated).futures-coreexcluded fromworkspace-hackunification (.config/hakari.toml) so the "feature off → dep absent from dep tree" guarantee holds workspace-wide, not just for published consumers.Filter(deferred from PR1 per its deviation log) is untouched; remains a follow-up.Open design point resolved
The plan listed three options for the "OwnedItem marker shape" — separate trait,
'staticbound, orClone + Send + 'static. Picked the trait route (OwnedEventStream) for symmetry with PR1'sBaseEventStream. The'staticbound is insufficient on its own becausefutures::Stream::Itemis a single concrete type and we still need a named associated type to plug into it; HRTB type equality (for<'a> Item<'a> = T) is blocked by the GAT'swhere Self: 'aclause leakingSelf: 'staticinto the HRTB. Full reasoning in the deviation log.Test plan
nix flake checkpasses (all 8 checks).cargo test -p nexus-store --features futures-bridge --test futures_bridge_tests— 7 tests pass:try_map → into_stream → try_collectyields all items in ordermap → into_streamyields owned closure outputs wrapped inOk(...)Nonekeeps returningNone)try_mapclosure error short-circuits identicallytokio::spawnIntoStream<TryMap<VecStream, ..>, ()>: Send.cargo tree -p nexus-store --no-default-features --edges normalshows zero futures crates (hakari exclude in place).cargo tree -p nexus-store --features futures-bridgeconfirmsfutures-coreIS present when the feature is on.Files
Cargo.toml— workspace deps:futures = "0.3"(dev-dep),futures-core = "0.3"(default-features = false)..config/hakari.toml—[final-excludes]block excludingfutures-corefrom workspace-hack unification.crates/nexus-store/Cargo.toml—futures-bridgefeature →dep:futures-core;futuresworkspace dev-dep; dev-dep enablesfutures-bridge.crates/nexus-store/src/stream/owned.rs(new) —OwnedEventStreamsub-trait +IntoStreambridge.crates/nexus-store/src/stream/mod.rs— feature-gated re-export.crates/nexus-store/src/stream/cursor.rs— feature-gatedEventStreamExt::into_stream()method.crates/nexus-store/src/lib.rs— feature-gated re-export.crates/nexus-store/tests/futures_bridge_tests.rs(new) — 7 tests covering the 4-category matrix.crates/workspace-hack/Cargo.toml— hakari output (regenerated).Plan / deviation log
/Users/joel/.claude/plans/cheerful-yawning-hopper.md/Users/joel/.claude/plans/cheerful-yawning-hopper-deviations.md— four new entries for PR2 (OwnedEventStream choice, futures-core-only impl, hakari exclude, module naming).🤖 Generated with Claude Code