Skip to content

feat(store): futures::Stream bridge for owning-output lending streams#171

Merged
joeldsouzax merged 1 commit into
mainfrom
feat/stream-futures-bridge
May 25, 2026
Merged

feat(store): futures::Stream bridge for owning-output lending streams#171
joeldsouzax merged 1 commit into
mainfrom
feat/stream-futures-bridge

Conversation

@joeldsouzax

Copy link
Copy Markdown
Contributor

Summary

PR2 of the dual-track stream refactor (plan: cheerful-yawning-hopper). Adds a feature-gated bridge from the GAT lending EventStream into futures_core::Stream so consumers can opt in to the futures ecosystem when they want owning output.

  • 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. Implemented for Map and TryMap only — not for raw cursors (their Item<'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.
  • 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() is the entry point (also feature-gated).
  • Hakari: futures-core excluded from workspace-hack unification (.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, 'static bound, or Clone + Send + 'static. Picked the trait route (OwnedEventStream) for symmetry with PR1's BaseEventStream. The 'static bound is insufficient on its own because futures::Stream::Item is 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's where Self: 'a clause leaking Self: 'static into the HRTB. Full reasoning in the deviation log.

Test plan

  • nix flake check passes (all 8 checks).
  • cargo test -p nexus-store --features futures-bridge --test futures_bridge_tests — 7 tests pass:
    • Sequence/Protocol: try_map → into_stream → try_collect yields all items in order
    • Sequence/Protocol: map → into_stream yields owned closure outputs wrapped in Ok(...)
    • Lifecycle: empty lending stream → empty futures stream
    • Lifecycle: bridge is fused after exhaustion (None keeps returning None)
    • Defensive Boundary: underlying-stream error propagates once then bridge terminates
    • Defensive Boundary: try_map closure error short-circuits identically
    • Send/spawn: bridge is driveable from tokio::spawn
  • Static assertion: IntoStream<TryMap<VecStream, ..>, ()>: Send.
  • cargo tree -p nexus-store --no-default-features --edges normal shows zero futures crates (hakari exclude in place).
  • cargo tree -p nexus-store --features futures-bridge confirms futures-core IS 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 excluding futures-core from workspace-hack unification.
  • crates/nexus-store/Cargo.tomlfutures-bridge feature → dep:futures-core; futures workspace dev-dep; dev-dep enables futures-bridge.
  • crates/nexus-store/src/stream/owned.rs (new) — OwnedEventStream sub-trait + IntoStream bridge.
  • crates/nexus-store/src/stream/mod.rs — feature-gated re-export.
  • crates/nexus-store/src/stream/cursor.rs — feature-gated EventStreamExt::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

  • Plan: /Users/joel/.claude/plans/cheerful-yawning-hopper.md
  • Deviation log: /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

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>
@joeldsouzax joeldsouzax merged commit 836dd98 into main May 25, 2026
2 checks passed
@joeldsouzax joeldsouzax deleted the feat/stream-futures-bridge branch May 25, 2026 12:52
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant