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
17 changes: 17 additions & 0 deletions .changeset/2026-08-28-async-runtime-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@dexpace/rx": minor
---

Add `@dexpace/rx`, the RxJS async-runtime bridge (Phase 8b, `SSE-41` / the non-collapsed `ASYNC-*` subset):

- `sseEvents$(stream)` and `typedSse$(stream, mapper)` — single-subscription `Observable` views of Phase 6b's
`SseStream` and `typedSseStream`. A second `subscribe()` surfaces `SseStream`'s own `SSE-26` guard through the
error channel rather than inventing a new restriction.
- `pageItems$(paginator)` and `pages$(paginator)` — cold, repeatable `Observable` views of Phase 6c's
`Paginator`, one independent fetch sequence per subscription (`PAGE-8`).
- Unsubscribing reaches the source even while a pull is suspended (`ASYNC-6`), so an idle SSE stream releases its
response body immediately instead of at the server's next event. This is the one clause RxJS's own
`from(asyncIterable)` does not satisfy, so the package ships a small internal bridge in its place; the
conformance suite pins both behaviors.
- Source errors reach the error channel unwrapped (`ASYNC-13`); no new error class.
- `rxjs` and `@dexpace/core` are peer dependencies, and the package has zero runtime dependencies (`SEAM-1`).
28 changes: 26 additions & 2 deletions bun.lock

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

46 changes: 46 additions & 0 deletions docs/open-items.md
Original file line number Diff line number Diff line change
Expand Up @@ -1413,6 +1413,52 @@ Recorded at implementation time. Verified against `docs/product-spec/15-instrume
`PIPE-2` fixes the `LOGGING` pillar step inside `RETRY` and `REDIRECT` pipelines. Consequently, `startSpan('http.client.request')` and metric increments (`http.client.request.count`, `http.client.request.duration`) execute per HTTP transmission attempt/hop. The higher-level logical operation span and HTTP-tracer lifecycle are owned by Phase 8a / `OBS-29`.


## Section M — Phase 8b (Async-Runtime Bridge, `@dexpace/rx`)

Recorded at implementation time. Verified against `docs/product-spec/18-asynchronous-runtime-adapter-contract.md`
(`ASYNC-1`..`ASYNC-22`) and `SSE-41`.

### M1 — The `AsyncIterable`→`Observable` Bridge Is Hand-Written, Not `rxjs`'s `from()` — **RECORDED** (2026-08-28)

8b's design (§1) and plan (Global Constraints) both instructed: do not hand-write the pull loop, use RxJS's own
`from(asyncIterable)`, and prove it satisfies `ASYNC-6`/`ASYNC-13`/`ASYNC-21` rather than assuming it. The proof
failed on one clause. `rxjs@7.8.2`'s async-iterable path tests `subscriber.closed` only *after* a pull resolves,
so unsubscribing while a pull is suspended never reaches the source. For pagination that is invisible; for SSE it
is the common case — an idle event stream is permanently suspended, so `unsubscribe()` would leave the response
body unreleased and the connection open until the server next sent something.

Resolved through the fallback both documents pre-authorized, scoped to that clause alone:
`packages/rx/src/from-async-iterable.ts` (`@internal`) adds a teardown that releases the caller-supplied source
and drives `iterator.return()`, release first so a suspended pull settles before the queued generator return.
No scheduler, no error re-wrapping, no buffering.

Full rationale in the plan's Self-Review. This is a deviation from the *plan's* implementation instruction, not
from the product spec — `ASYNC-6`/`ASYNC-21`/`SSE-41` are satisfied as written, and
`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`'s Phase 8b rows are unaffected.

**Trigger:** an RxJS release that closes the gap. `from-async-iterable.conformance.test.ts`'s last case asserts
the defect (`returns === 0` after an idle unsubscribe) and fails when it is fixed; at that point delete the
module and go back to `from()`.

### M2 — `ASYNC-*` IDs Marked 🚫 Are Not Yet Satisfied Anywhere — **SCHEDULED** (Phase 8a)

`ASYNC-1`, `-2`, `-5`, `-15`, `-16`, `-17`, `-20`, and `-22` collapse onto `TRANSPORT-*` twins (`TRANSPORT-23`,
`-21`, `-9`, `-15`/`-16`, `-29`, and the `SEAM-16` body-ownership invariant). The 8b checklist marks them 🚫
"collapses onto Phase 8a," which is the correct disposition but reads, at a glance, like a closed row. **No
shipped package implements those `TRANSPORT-*` requirements yet** — 8a (`transport-fetch`/`transport-undici`) has
not executed. Recorded so an appendix-B sweep run between 8b and 8a does not count eight `MUST`s as covered.

**Trigger:** Phase 8a landing. Its checklist owns the ✅ for each twin.

### M3 — `ASYNC-18` Confirmed a Full-Port Collapse at Implementation Time — **RESOLVED** (2026-08-28)

8b's design predicted that no adapter in this port needs a non-blocking scheduled-delay primitive, correcting the
segmentation design's narrower "8b-only scope boundary" framing. The as-built package confirms it: `@dexpace/rx`
contains no timer, no scheduler, and no backoff — the four wrappers only iterate what they are handed. SSE
reconnection stays caller-owned (`SSE-38`) and retry/backoff stays in 5a's engine. Already reflected in
`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md` Item 1; no further action.


## Maintaining this file

Add an entry the moment a gap is found, not when it is fixed — the failure mode this file prevents is a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Phase 8b — Async-Runtime Bridge — Checklist

**Status: EXECUTED.** Every ✅ below names code and tests that exist on this branch, not a plan step. Verified
against `docs/product-spec/18-asynchronous-runtime-adapter-contract.md` (`ASYNC-1` through `ASYNC-22`),
`docs/product-spec/13-server-sent-events-and-streaming.md` (`SSE-41`), plus Task 1 through Task 4 deliverables,
package builds, and API reports.

**One implementation deviation**, recorded in full in the plan's Self-Review: the `AsyncIterable`→`Observable`
bridge is `packages/rx/src/from-async-iterable.ts`, not RxJS's own `from()`, because `rxjs@7.8.2` does not reach
the source when a subscription is torn down while a pull is suspended — the `ASYNC-6` clause an idle SSE stream
depends on. Every row citing that module below is citing the reason it exists. The `🚫` rows that name Phase 8a
are **not satisfied yet**: they collapse onto `TRANSPORT-*` requirements no shipped package implements — see
`docs/open-items.md` §M2.

**Legend:** ✅ Implemented and tested — 🚫 Not built (permanent simplification / collapse, named reason) — ⏳ Deferred
(named target phase) — N/A Not applicable in this port.

## 18.1 Completion and failure delivery

| ID | Level | Requirement gist | Status | Where |
|---|---|---|---|---|
| ASYNC-1 | MUST | Single-value completion future delivers non-null Response on success, failure channel on no response | 🚫 | Collapses onto `TRANSPORT-23` (Phase 8a) — for `Transport`, `send()` returns `Promise<Response>` directly |
| ASYNC-2 | MUST | Construction-time failure via failure channel, not sync throw | 🚫 | Collapses onto `TRANSPORT-21` (Phase 8a) |

## 18.2 Cancellation modes

| ID | Level | Requirement gist | Status | Where |
|---|---|---|---|---|
| ASYNC-3 | MUST | Cancel-with-interrupt vs without on worker thread | N/A | Node event loop has no worker-thread-pool transport model to interrupt (`SEAM-18` disposition) |
| ASYNC-4 | MUST | Ordered interrupt delivery preventing pooled-thread poisoning | N/A | Node event loop has no pooled worker threads to poison (`SEAM-18` disposition) |
| ASYNC-5 | MUST | Orphaned closeable result closed exactly once on race | 🚫 | Collapses onto `TRANSPORT-9` (Phase 8a) |
| ASYNC-6 | MUST | Bidirectional cancellation across adapter | ✅ | `packages/rx/src/from-async-iterable.ts`'s teardown (release the source, then `iterator.return()`). Asserted across all four paths — synchronous unsubscribe from inside `next()`, unsubscribe while a pull is suspended, unsubscribe before the first emission, and a rejected release — in `from-async-iterable.conformance.test.ts`, `sse.test.ts`, `pagination.test.ts`, and on real Node in `test/node-conformance/rx-bridge.test.mjs`. The same suite pins RxJS's native `from()` **failing** this clause |
| ASYNC-7 | SHOULD | Document interrupt-mode choice per adapter | N/A | Vacuous: no blocking worker thread calls to interrupt |

## 18.3 Logging-context propagation

| ID | Level | Requirement gist | Status | Where |
|---|---|---|---|---|
| ASYNC-8 | SHOULD | Propagate logging context across thread/scheduler hops | ✅ | Node `AsyncLocalStorage` auto-propagation through promise chains/async iteration; the package installs no RxJS scheduler, which is what keeps the continuation chain intact. Stated in `sseEvents$`/`typedSse$` TSDoc, including the caller-introduced `observeOn`/`subscribeOn` boundary |
| ASYNC-9 | MUST | Save, install, restore logging context | ✅ | Node `AsyncLocalStorage` auto-propagation invariant |
| ASYNC-10 | MUST | Capture logging context at logical caller point | ✅ | Node `AsyncLocalStorage` captures per subscription at iteration pull time |
| ASYNC-11 | MUST | Safe when no logging context backend installed | ✅ | `AsyncLocalStorage` handles undefined store gracefully |
| ASYNC-12 | MUST | Explicit transfer at thread boundary where auto-inheritance absent | N/A | Single-threaded event loop; continuation-local storage auto-propagates |

## 18.4 Error unwrapping and blocking bridge

| ID | Level | Requirement gist | Status | Where |
|---|---|---|---|---|
| ASYNC-13 | MUST | Unwrap async framework wrapper exceptions to original cause | ✅ | `packages/rx/src/from-async-iterable.ts` passes a thrown value straight to `subscriber.error`; asserted in `from-async-iterable.conformance.test.ts` (`RangeError` in, same `RangeError` out) and `sse.test.ts` (a throwing `SseMapper`) |
| ASYNC-14 | MUST | Async->sync blocking bridge honoring thread interruption | N/A | Inapplicable in Node — no blocking HTTP client bridge (`SEAM-18` disposition) |

## 18.5 Lifecycle

| ID | Level | Requirement gist | Status | Where |
|---|---|---|---|---|
| ASYNC-15 | MUST | Close/dispose operation idempotent, ownership-aware, interrupt-safe | 🚫 | Owned by Phase 8a `TRANSPORT-15`/`16`; `@dexpace/rx` owns no background thread pools |
| ASYNC-16 | SHOULD | Graceful executor shutdown on close | 🚫 | Owned by Phase 8a |
| ASYNC-17 | SHOULD | No-op default close for lightweight/functional transports | 🚫 | Owned by Phase 8a (`transport-fetch`) |

## 18.6 Delay, options, and streaming

| ID | Level | Requirement gist | Status | Where |
|---|---|---|---|---|
| ASYNC-18 | MUST | Non-blocking scheduled-delay primitive | N/A | Resolved N/A to this port: `@dexpace/rx` does no reconnection, retry, or backoff; SSE reconnection is caller-owned; pagination retry lives in pipeline layer |
| ASYNC-19 | MUST | Per-call request options threaded through overloads | N/A | Resolved N/A: `@dexpace/rx` wraps already-constructed `SseStream` / `Paginator` instances; does not initiate new HTTP calls |
| ASYNC-20 | MUST | Delivered Response body not closed on late future cancel | 🚫 | Restates `SEAM-16` / transport invariant owned by Phase 8a |
| ASYNC-21 | MUST | Reactive streaming adapter (SSE) honors backpressure, completes on end-of-source, propagates errors without swallowing, single-subscriber | ✅ | `packages/rx/src/sse.ts`: `sseEvents$`, `typedSse$`, over `from-async-iterable.ts`'s one-pull-per-emission loop. `from-async-iterable.conformance.test.ts` (poll-once-per-demand, complete-on-end, error passthrough), `sse.test.ts` (single-subscriber via `SSE-26`), `test/node-conformance/rx-bridge.test.mjs` |
| ASYNC-22 | MUST | Safe for concurrent calls | 🚫 | Collapses onto `TRANSPORT-29` (Phase 8a) |

## 13.7 Server-Sent Events

| ID | Level | Requirement gist | Status | Where |
|---|---|---|---|---|
| SSE-41 | MAY | Reactive SSE adapter with fatal/non-fatal split and documented source ownership | ✅ | `packages/rx/src/sse.ts`: `sseEvents$`, `typedSse$`. Source ownership is documented on both functions and in `packages/rx/README.md` (the adapter closes the stream on unsubscribe; the caller owns reconnection). The fatal/non-fatal split collapses — JavaScript has no catchable-fatal tier, per the design doc's Deviation Ledger. Asserted in `sse.test.ts` and `from-async-iterable.conformance.test.ts` |
46 changes: 46 additions & 0 deletions docs/superpowers/plans/2026-07-28-phase8b-async-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,52 @@ git commit -m "feat(rx): promote public barrel for @dexpace/rx"

## Self-Review

**Executed 2026-08-28.** All four tasks shipped; the full gate sequence is green. One deviation, recorded below
rather than left silent.

### Deviation Ledger addition — the `AsyncIterable`→`Observable` bridge is hand-written

**What the plan said.** Global Constraints: "Do not hand-write an `AsyncIterable`-to-`Observable` pull loop. Use
RxJS's own `from()`." Task 1 Step 3 named the escape hatch: if a conformance clause fails against the installed
RxJS, write the minimal wrapping `Observable` that closes *exactly* that clause and record it here.

**What was found.** `rxjs@7.8.2`'s async-iterable path (`internal/observable/innerFrom.js`) is a bare `for await`
loop that tests `subscriber.closed` only *after* a pull resolves:

```js
async function process(asyncIterable, subscriber) {
for await (const value of asyncIterable) {
subscriber.next(value);
if (subscriber.closed) return;
}
subscriber.complete();
}
```

Unsubscribing while a pull is suspended therefore reaches the source only if and when the source produces
again. For pagination that is invisible (a page fetch always settles). For SSE it is the failure mode that
matters most: an idle event stream is *permanently* suspended on `next()`, so `subscription.unsubscribe()`
leaves the response body unreleased and the connection open until the server happens to send something. That is
`ASYNC-6`'s bidirectional-cancellation clause, unsatisfied — and `SSE-30`'s release obligation with it.

**What was built.** `packages/rx/src/from-async-iterable.ts` (`@internal`, ~60 lines): the same pull loop, plus a
teardown that releases the caller-supplied source and drives `iterator.return()` on unsubscription. Release runs
*before* the iterator return, because closing the source is what settles the suspended pull that an async
generator's queued `return()` would otherwise sit behind. Scope is exactly the failing clause — no scheduler, no
error re-wrapping, no retry, no buffering.

**How it stays honest.** `from-async-iterable.conformance.test.ts`'s last case asserts the *defect* in RxJS's own
`from()` (`returns` stays `0` after an idle unsubscribe) alongside this module's `1`. When a future RxJS closes
the gap that case fails, and the reviewer's instruction is in the file header: delete the module and go back to
`from()`. `test/node-conformance/rx-bridge.test.mjs` proves the same cancellation path on real Node, since
whether the release lands depends on Node's `ReadableStream.cancel()` and async-generator `return()` queueing.

**Not a deviation from the product spec.** `ASYNC-6`/`ASYNC-21`/`SSE-41` are satisfied as written; the deviation
is from this plan's own implementation instruction, which named this outcome as an allowed one. Nothing is added
to `docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`, whose Phase 8b rows
(`ASYNC-21`'s fatal/non-fatal collapse, `ASYNC-18`'s full-port collapse) are unaffected.


- [ ] Task 1's conformance suite passed against the installed RxJS version with no fallback needed — or, if a
fallback was needed, it is scoped to exactly the failing clause and recorded in this section as a Deviation
Ledger addition to the design doc.
Expand Down
Loading
Loading