Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/body-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@dexpace/core": minor
---

Add the core Body domain interface and implementations (ByteArrayBody, StringBody, FormUrlEncodedBody, StreamBody, MultipartBody, materialize, TypedResponse, HttpStatusError, toHttpError, withRequestLogging, withResponseLogging).

`RequestBuilder.body` and `ResponseBuilder.body` narrow from `unknown` to `Body | undefined` and `ReadableStream<Uint8Array> | null` respectively — a breaking parameter-type change per `styleguide/typescript/10-api-design.md`. Resolving Phase 3b's open D1 finding (`docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md`, "Open Findings — Phase 3b Validation Review"): kept as **minor** rather than major because `@dexpace/core` is still pre-1.0 (`0.0.0`), where a 0.x breaking change is conventionally released as minor (semver's own carve-out for initial development, https://semver.org/#spec-item-4). Revisit at 1.0.
5 changes: 5 additions & 0 deletions .changeset/io-contracts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@dexpace/core": patch
---

Internal: byte-streaming primitives for product-spec §5 (IO-1–IO-42). No public API change.
3 changes: 3 additions & 0 deletions bun.lock

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

Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ requirement ID in `docs/product-spec/05-i-o-contracts.md`, as dispositioned by
|---|---|---|---|---|
| IO-11 | MUST | `exhausted()`, single-byte read, count-less read of all remaining (empty when exhausted) | ✅ | Task 6 |
| IO-12 | MUST | Exact-count read returns exactly N or fails; never short | ✅ | Task 6, asserted across chunk boundaries and on the short path |
| IO-13 | MUST | UTF-8 and explicit-charset reads, with symmetric write-side encodings | ✅ (read) / ⚠️ (write, bounded) | Task 7 (read: any `TextDecoder` label, ISO-8859-1 round-trip per the requirement's own conformance note), Task 9 (write: **UTF-8 and ISO-8859-1 only**). `TextEncoder` is UTF-8-only and `SEAM-1` forbids an encoding dependency, so full symmetry is unreachable; any other label throws rather than silently re-encoding. Ledgered deviation |
| IO-13 | MUST | UTF-8 and explicit-charset reads, with symmetric write-side encodings | ✅ (read) / ⚠️ (write, bounded) | Task 7 (read: any `TextDecoder` label), Task 9 (write: **UTF-8 and ISO-8859-1 only**), plus two `fast-check` round-trip property tests in `buffered-sink.test.ts` — sink-out/source-back through UTF-8, and through ISO-8859-1 asserting one byte per code point, which is what distinguishes an honored charset from a silent UTF-8 re-encoding. `TeeSink`'s own `writeUtf8`/`writeString` are asserted to mirror the primary's exact encoded bytes and to refuse an unsupported label identically. `TextEncoder` is UTF-8-only and `SEAM-1` forbids an encoding dependency, so full symmetry is unreachable; any other label throws rather than silently re-encoding. Ledgered deviation |
| IO-14 | MUST | Line read consumes the terminator; `\n` and `\r\n` both terminate; lone `\r` is content; final unterminated line as-is; absent when exhausted first | ✅ | Task 7, including a `fast-check` property test with **adversarially generated chunk boundaries**, so a terminator straddling two stream chunks is covered — the case the requirement's rationale names and hand-picked examples miss |
| IO-15 | MUST | Skip advances exactly N, fails if fewer remain; `skip(0)` a no-op even at/after EOF | ✅ | Task 6 |
| IO-16 | SHOULD | Read-only host-native byte-stream bridge; symmetric writable bridge; closing the bridge closes the owner | ✅ | Task 12. Host-native means `ReadableStream`/`WritableStream` for this port, per `sdk-design/03` §3.1 — no `node:` import; Task 13 Step 9 greps to enforce that |
Expand All @@ -61,7 +61,7 @@ requirement ID in `docs/product-spec/05-i-o-contracts.md`, as dispositioned by
| IO-26 | MUST | Tap capacity limit; default effectively unbounded; a limit of 0 mirrors nothing while forwarding everything | ✅ | Task 10 (`Number.POSITIVE_INFINITY` default, spelled as a value rather than a magic number); all three cases asserted |
| IO-27 | MUST | Mirror BEFORE forwarding; clear staging even on a failed write so no stale bytes prepend | ✅ | Task 10, both clauses asserted; staging cleared in a `finally` so it holds on the throwing path |
| IO-28 | MUST | No direct backing-buffer handle; attempting it fails, directing callers at the typed writes | ✅ | Task 10 (`get buffer(): never`) |
| IO-29 | MUST | Tee's own flush/close/emit forward to the PRIMARY only, leaving the tap intact | ✅ | Task 10, with snapshot-after-close asserted |
| IO-29 | MUST | Tee's own flush/close/emit forward to the PRIMARY only, leaving the tap intact | ✅ | Task 10. All three asserted: `close` with snapshot-after-close, and `flush`/`emit` both by returning the tee with the tap intact and — the observable proof they are not swallowed by the decorator — by rejecting with `ClosedResourceError` once the primary is closed, which only the primary can raise |

## 5.6 Provider factories, timeouts, and thread-safety

Expand All @@ -80,10 +80,11 @@ requirement ID in `docs/product-spec/05-i-o-contracts.md`, as dispositioned by
| Nothing enters the published API surface | Design decision (styleguide 10.3, Phase 2's `Serde<T>` precedent) | ✅ | Task 13 Step 8 — `git diff --exit-code packages/core/etc/core.api.md` must produce no output. Mechanical proof, not a review promise |
| No runtime dependency added | `SEAM-1` | ✅ | Task 13 Step 7 runs `verify:seam-1`; `mitata` is a root devDependency only |
| No `node:` import in core | `sdk-design/03` §3.1, runtime-agnosticism | ✅ | Task 13 Step 9 greps `packages/core/src/` and fails on any match |
| Property tests where invariants exist | styleguide 11.5 | ✅ | Task 4 (`ByteQueue` ×4), Task 7 (`readUtf8Line`), Task 8 (views ×2), Task 10 (`TeeSink` wire payload) |
| Property tests where invariants exist | styleguide 11.5 | ✅ | Task 4 (`ByteQueue` ×4), Task 7 (`readUtf8Line`), Task 8 (views ×2), Task 9 (charset round-trips ×2), Task 10 (`TeeSink` wire payload) |
| Rejection assertions are awaited and attributable | styleguide 11.9 | ✅ | `test-support/rejection.ts`. bun types `.rejects.toThrow()` as `void`, so the plan's `await expect(…).rejects` form fails `@typescript-eslint/await-thenable`; the helper awaits the promise and returns the reason instead, with no `eslint-disable`. Ledgered |
| Negative-space and cleanup assertions | styleguide 11.9, 13.9 | ✅ | Idempotent close (Tasks 4, 5, 6, 9), both IO-42 directions (Tasks 4, 6), parent-close invalidation (Task 8), failed-write tap capture (Task 10) |
| Determinism — no fake clocks needed | styleguide 11.8 | ✅ | IO-40 means this layer owns no timer; every stream under test is built from an in-memory array |
| Fakes over `mock.module` | styleguide 11.3 | ✅ | Task 5's `test-support/fake-stream.ts`, excluded from the build via `tsconfig.build.json` |
| Fakes over `mock.module` | styleguide 11.3 | ✅ | Task 5's `test-support/fake-stream.ts` and `test-support/rejection.ts`, both excluded from the build via `tsconfig.build.json`'s `src/io/test-support/**` |
| No type-level tests | styleguide 11.6 | ✅ (correctly absent) | 11.6 requires them for public generics and conditional types; this phase publishes neither. Stated rather than manufactured |
| Committed baseline bench | styleguide 15.6 | ✅ | Task 13, `byte-queue.bench.ts`. Baseline only — no optimization applied, no 15.10 ledger notes, per 15.1/15.6's "do not tune ahead of a profile" |
| 80% aggregate coverage floor | `NFR-5` | ✅ | Task 13 Step 7 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,10 @@ layer where the temptation to dump the offending bytes into the message is stron
| `TeeSink` as a sink decorator | `sdk-design/03` §3.1 phrasing | `TransformStream` queueing muddies `IO-27`'s mirror-before-forward ordering; §3.1's substantive point is untouched |
| `IO-30` resolution half, `IO-39` not built | product-spec §5.6 | No registry exists — same class as `SEAM-5`–`SEAM-10` |
| `IO-38` not applicable | product-spec §5.4 | The requirement is about a close on one thread invalidating a slice being read on another, so it presupposes an instance can reach a second thread. None can. **Class instances are not structured-cloneable at all** — `postMessage`/`structuredClone` preserve neither prototypes nor `#private` fields, so a `ByteQueue` or `BufferedSource` sent to a worker arrives as a plain object with no methods and no close state to observe. `BufferedSource` is doubly excluded: a `ReadableStreamDefaultReader` is neither cloneable nor transferable. A raw `ArrayBuffer` *can* be transferred, but it carries no close state and derives no slices, so the hazard has no subject |
| `"DOM.AsyncIterable"` added to `tsconfig.base.json`'s `lib` | Phase 2's `lib: ["ES2022", "DOM"]` baseline | `IO-16`'s `toReadableStream()` returns a `ReadableStream`, and asserting it with `for await (const chunk of …)` needs the async-iteration declarations, which TypeScript ships in a separate `lib` entry from `DOM`. Workspace-wide because the `lib` array is; no runtime effect and no new dependency (`SEAM-1` untouched), and the API report is unchanged. The alternative — driving the bridge test through `getReader()` — was rejected because async iteration is how a consumer will actually use the bridge, so the test should exercise that path |
| `packages/core/src/invariant.ts` created in this phase | The plan's prerequisite, which lists `invariant` as existing from Phase 1 | Phase 1 shipped `requireField` for HTTP-4's required-field message, not a general assertion primitive, so `invariant` (styleguide 5.6, 8.7) did not exist. `IO-3`, `IO-10`, and `IO-21` all need it, so it was added here as an `@internal` module with `InvariantViolation` as its own class. Nothing in `src/http/` was changed to route through it — Phase 1's `requireField` still owns HTTP-4's message |
| `ByteQueue.takeBytes` checks `MAX_BYTE_ARRAY_LENGTH` *before* the short-source check | The plan's Task 3 code, which checked size first | With the plan's ordering, an over-limit request on a short queue raised `EndOfStreamError`, hiding the real problem, and the plan's own `IO-9` test (`takeBytes(MAX + 1)` expects `AllocationLimitError`) could not pass. `IO-9`'s actionable-refusal requirement wins over reporting a size mismatch that is a consequence of the over-limit ask |
| Rejection assertions go through a `rejection()` test helper, not `await expect(…).rejects.toThrow(…)` | The plan's test code, which used `await expect(…).rejects` throughout | bun types `rejects` as `Matchers<unknown>` whose `toThrow()` returns `void`, even though at run time it returns a promise. So the plan's form fails this repo's type-aware `@typescript-eslint/await-thenable` gate, and dropping the `await` to satisfy lint leaves the assertion racing test teardown — bun still fails the run, but the failure can attribute to a later test. `test-support/rejection.ts` awaits the promise, returns the rejection reason, and fails loudly if the promise resolves, so every assertion is awaited and attributable with no `eslint-disable` |
| Write-side charsets limited to UTF-8 and ISO-8859-1 | `IO-13`'s "symmetric write-side encodings" | `TextEncoder` is UTF-8-only and `SEAM-1` forbids an encoding dependency. Read side stays fully general via `TextDecoder`; the write side covers the two encodings HTTP needs, and `IO-13`'s own conformance note names ISO-8859-1 as the non-UTF-8 case. Any other label throws rather than silently corrupting bytes. The `writeUtf8(begin, end)` substring-range overload is subsumed by `String.prototype.slice` at the call site |

## Testing
Expand All @@ -416,7 +420,11 @@ invariant-bearing functions, and §5 is almost nothing else:
covered; `IO-14`'s own rationale calls out surviving slice-window boundaries, and that is exactly the case
hand-picked examples miss.
- **`readString`/`writeString`** — round-trip through UTF-8 and through ISO-8859-1 (`IO-13`, whose conformance note
names a non-UTF-8 charset explicitly).
names a non-UTF-8 charset explicitly). The ISO-8859-1 generator excludes code points `0x80`–`0x9F`: the WHATWG
Encoding Standard maps the label `iso-8859-1` onto windows-1252, so `TextDecoder` returns U+20AC for `0x80`
rather than U+0080. That is the platform's asymmetry, not the sink's — the write side is a straight
code-point-to-byte map — and HTTP needs none of those C1 controls. Recorded here so Phase 9 does not read the
excluded band as an untested gap.
- **View independence** — N views at arbitrary offsets and counts each read the same bytes a direct read at that
window would, and no view's read advances another's cursor (`IO-19`, `IO-20`, `IO-23`).
- **`TeeSink`** — for arbitrary write sequences and arbitrary tap caps, the primary receives the exact concatenation
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"fast-check": "^3",
"globals": "^17.8.0",
"gts": "^7",
"mitata": "^1",
"publint": "^0.3",
"typescript": "^5.8",
"typescript-eslint": "^8"
Expand Down
Loading
Loading