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
18 changes: 18 additions & 0 deletions .changeset/2026-08-27-codec-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@dexpace/codec-json': minor
---

Initial release of the reference JSON wire codec: `jsonSerde()`, the `Tristate` PATCH replacer (on by
default, opt-out is an explicit `{tristate: false}`), and the `tristate()` / `tristateObject()` decode
combinators. Depends on nothing beyond a `@dexpace/core` peer — the schema that witnesses each decode
is the caller's, so no schema library is a dependency of either package.

Encoding details worth knowing at the call site: a top-level `undefined`, function, or symbol raises
`SerializationError` rather than encoding as the `null` literal — all three are unencodable values,
and substituting `null` would send a PATCH server a meaningful "clear this field" the caller never
wrote. Nested occurrences keep ordinary `JSON.stringify` behaviour. The `SERDE-20` top-level Tristate
degradation (a top-level Absent or Null still encodes as `null`) is resolved by the serializer before
`JSON.stringify` runs, because a replacer cannot tell the top-level value from a key literally named
`""`; a caller composing their own `JSON.stringify(v, tristateReplacer)` therefore gets the nested and
array-element behaviour but must route through `jsonSerde()` for the top level.

89 changes: 89 additions & 0 deletions .changeset/2026-08-27-pagination-engine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
'@dexpace/core': minor
---

Add the pagination engine for product-spec §12 (`PAGE-1`–`PAGE-36`). The public surface is `Paginator<T>`
with its two views, the `Page<T>` resource, the `PageInfo<T>` / `pageInfo()` pair and the
`PaginationStrategy<T>` interface, three built-in strategies (`cursorStrategy()`, `pageNumberStrategy()`,
`linkHeaderStrategy()`), the fetcher-driven front end `paginateWithFetchers()` with `PagingOptions` and
`FetcherPage<T>`, and the `PaginationError` leaf.

The engine drives a `Transport` directly and stays serde-agnostic: item extraction is a caller-supplied
callback on every built-in strategy, never a `Serde`. Resilience composes from outside — 4c's `Runtime` is
itself a `Transport`, so a full retry/redirect/auth pipeline drops in as the `transport` field with no
pagination-side change, recorded at `docs/open-items.md` §J5. The query splice and the `Link`
tokenizer stay internal; publishing them would stand a second URL-manipulation surface next to Phase 1's
`QueryParams`, which is the confusion the one-encoder rule exists to avoid.

What landed under `packages/core/src/pagination/`: `page.ts`, `strategy.ts`, `paginator.ts`,
`strategies.ts`, `link-header.ts`, `query-splice.ts`, `fetchers.ts`, and `errors.ts`, plus
`test/node-conformance/pagination.test.mjs`.

Three files changed outside it. `packages/core/src/http/query-params.ts` now exports
`encodeQueryComponent`/`decodeQueryComponent` (both `@internal`, so the API report is unaffected) —
`PAGE-22` restates HTTP-29's encoding rule verbatim, and two encoders in one codebase is a drift bug
waiting to happen. `packages/core/src/testing/fake-transport.ts` gains `sentOptions`/`sentSignals`
accessors and an init-object overload on `countingResponse()`. And `tsconfig.base.json` adds
`ESNext.Disposable` to `lib` — see the caveat below, because that one reaches consumers.

Five design calls worth recording:

- **Each page is closed *before* any of its items are yielded**, not in a `finally` after. `PAGE-11`
mandates the ordering and `sdk-design-nodejs/07` §7.1's illustrative snippet shows the opposite — it
closes after the yield, which holds the response open for the entire item walk and still passes the
requirement's stated conformance test. Materialized items survive close (`PAGE-2`), so closing first
costs nothing and means abandoning iteration mid-page can never strand a connection, however long the
consumer takes. An erratum callout was added to §7.1; recorded at `docs/open-items.md` §J1.
- **`PaginationStrategy.parse` is asynchronous, against `PAGE-5`'s literal wording.** The requirement says
a strategy reads what it needs "synchronously inside parse"; this runtime has no synchronous body read,
because the bytes may not have arrived. Every enforceable part of the intent — isolated, non-mutating,
one read, no retained body — survives the promise and is stated on the interface, since none of it is
expressible in the type system. Recorded at §J2. It cannot be "fixed" back to a synchronous signature.
- **The query splice is hand-rolled rather than `URLSearchParams` or `QueryParams`.** Both re-serialize
the *whole* query through their own canonical encoding on every mutation: untouched parameters get
reordered and re-encoded, against `PAGE-21`'s byte-for-byte rule, and a space becomes `+` rather than
the `%20` this port standardizes on. `query-splice.ts` tokenizes the raw query substring and copies
every untargeted byte through, sharing only the component *encoder* — the part `PAGE-22` and `HTTP-29`
genuinely agree on (§J4).
- **`Link` parsing is a scanner, not a regular expression.** The separator rules are context-sensitive in
two directions at once: a comma splits link-values only outside both angle brackets and quoted strings,
and a semicolon splits parameters under the same condition — and quoted strings support `\"` escapes, so
quote tracking cannot be a simple toggle. A target that fails to resolve is end-of-stream, not an error
(`PAGE-19`), which is one of the few places in this codebase where swallowing an exception is the
specified behavior rather than a smell.
- **`items()` is re-iterable and `pages()` is single-use.** The asymmetry is deliberate: each `items()`
walk closes every page before yielding, so a second iteration simply drives a second fetch sequence
(`PAGE-8`), while `pages()` hands out live connection-owning objects whose re-iteration would
double-consume unclosed resources (`PAGE-14`). `paginateWithFetchers()` is single-use for the same
reason — a second loop would re-run `first()` and break `PAGE-34`'s "exactly once" (§J6).

Limits worth knowing at the call site:

- **`Page` declares `implements AsyncDisposable` unconditionally, and this package's declared `lib` grew
`ESNext.Disposable` to make that compile.** A consumer compiling the published `.d.ts` needs the same
lib entry (`"ESNext.Disposable"`, or `esnext`) or `Page` will not typecheck for them. This is also the
one place the SDK is now internally inconsistent about explicit resource management: `Response`
(`HTTP-38`) and Phase 6b's `SseStream` install `[Symbol.asyncDispose]` behind a runtime guard precisely
because the declared `engines.node` floor is `>=20.3` and the symbol landed in 20.4, where a computed
key evaluating to `undefined` binds the method to the string `"undefined"` instead. `Page` takes the
unguarded route (§J3), so on the declared floor `await using page = ...` does not dispose and the class
carries a stray `"undefined"` method. `test/node-conformance/pagination.test.mjs` does not catch this —
its `page[Symbol.asyncDispose]` lookup coerces the key the same way the class definition did, so the
assertion passes on 20.3 without exercising anything. Resolving this one way or the other is a
floor-bump decision, not a pagination one.
- **Cancellation cannot reach a response the engine never received** (`PAGE-33`). If `signal` aborts
before the transport delivers, releasing that response is the transport's job. A request already
dispatched may still complete after the abort; when it does, the engine closes and discards it rather
than yielding it.
- **`PaginationError` is reserved for engine misuse** — a non-positive `maxPages` at construction
(`PAGE-9`), or a second iterator on a single-use view (`PAGE-14`). Transport, parse, and close failures
propagate as whatever the underlying layer raised, because `PAGE-28` requires the original cause to
surface rather than a pagination-flavored wrapper (§J8).
- **Ownership transfers to the page.** A fetcher builds a `Page` and must not close its response; the
engine closes it as the consumer advances and at exhaustion. A fetcher that throws *before* building the
page still owns whatever response it opened — the engine never saw it and has no handle to close it
with.
- **Two built-in strategies defend against servers that never signal termination.** `cursorStrategy`
treats an empty-string cursor as end-of-stream alongside `null`, and `pageNumberStrategy` stops on an
empty item list before any arithmetic runs. Both would otherwise walk forever against a server that
keeps answering past the end.
18 changes: 18 additions & 0 deletions .changeset/2026-08-27-serde-seam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@dexpace/core': minor
---

Add the serde seam. `Serde`/`Serializer`/`Deserializer` are reshaped around an explicit schema
witness supplied at each decode call, closing `SEAM-21` — `Serde` is no longer generic in a payload
type, because a bundle is per wire format, not per DTO. Ships alongside it: `Tristate<T>` and its
helpers for PATCH three-state fields, the `SerializationError`/`DeserializationError` leaves with an
`isSerdeError` guard, `serdeBody()` (the serde's own media type becomes the default `Content-Type`),
and the `decodeResponse()`/`decodeSuccessResponse()` response handlers.

`decodeResponse()` passes through every error already in the SDK's typed tree rather than re-typing
it, so a stream failure raised by this SDK's I/O layer reaches the caller unwrapped (`SERDE-12`). A
foreign transport's stream error is indistinguishable from a non-conforming codec leaking one and is
still surfaced as `DeserializationError`; both handlers' `@throws` state that limit and name the
affected transports. A body already locked by another consumer raises a plain `TypeError`, matching
`Response.bytes()`, instead of being reported as a malformed payload.

73 changes: 73 additions & 0 deletions .changeset/2026-08-27-sse-subsystem.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
'@dexpace/core': minor
---

Add the Server-Sent Events subsystem for product-spec §13 (`SSE-1`–`SSE-41`). The public surface is
`sseStreamFrom()` and the `SseStream` facade it returns, `typedSseStream()` with the `MapperOutcome<T>`
union and its `mapperValue()` / `MAPPER_SKIP` / `MAPPER_DONE` constructors, the `SseEvent` value with
`makeSseEvent()` / `sseEventsEqual()` / `sseEventToString()` / `isSseEventEmpty()`, and two error leaves,
`SseStreamError` and `SseLineTooLongError`.

Pull-based with no read-ahead (`SSE-39`): one consumer pull drives at most one parse, and nothing is
buffered speculatively. No reconnection and no `Last-Event-ID` continuity (`SSE-38`) — both remain the
caller's responsibility, and both are now gate-enforced rather than merely documented.

The line reader and the parser stay internal. They are driven only through the facade, and publishing them
would publish a way to violate `SSE-17`'s non-ownership contract by accident: neither closes the
`BufferedSource` it reads, because lifecycle belongs to `SseStream` alone.

What landed under `packages/core/src/sse/`: `event.ts` (the frozen value and its operations),
`line-reader.ts` (byte-level line framing plus the opt-in cap), `parser.ts` (the field grammar and
dispatch rules), `stream.ts` (the resource-owning facade and `sseStreamFrom()`), `typed.ts` (the mapper
adapter), and `errors.ts`. Outside the package: `scripts/verify-sse-37.mjs` with its own test, a CI step
that runs it, and `test/node-conformance/sse.test.mjs`.

Four design calls worth recording:

- **SSE frames its own lines rather than reusing `BufferedSource.readUtf8Line()`.** Phase 3a's primitive
treats `\n` and `\r\n` as terminators but keeps a lone `\r` as line *content* (`IO-14`); `SSE-2`
requires the opposite, where a lone CR terminates a line by itself. Both contracts are normative for
their own subsystem, so reshaping the frozen Phase 3a surface for one consumer was the wrong trade. The
duplication is deliberate and recorded at `docs/open-items.md` §I2 so Phase 10's deviation review does
not read it as accidental. The awkward case it exists to get right is a `\r` ending one chunk whose `\n`
begins the next: the pending CR is held until the following byte — or EOF — is known, so the pair
resolves to a single terminator.
- **`SSE-37`/`SSE-38` are enforced by a script, not by a type.** Nothing in the type system would catch
somebody "helpfully" adding a reconnect loop or a `Last-Event-ID` header, so `verify:sse-37` scans
`src/sse/` for serde imports and for reconnection markers. It scans **comments-stripped** source on
purpose: the requirement forbids the code path, not the documentation of its absence, and "this
subsystem never reconnects; that is the caller's job" is the single most likely sentence to appear in a
TSDoc there. A gate that fails on its own requirement's explanation is a gate the next person deletes
instead of the comment.
- **`[Symbol.asyncDispose]` is installed at run time, not declared on the class.** The declared
`engines.node` floor is `>=20.3` and the symbol landed in Node 20.4, where a computed key that evaluates
to `undefined` binds the method to the string `"undefined"` instead — wrong, silent, and only at run
time. Declaring the member would also break consumers compiling the published `.d.ts` on a plain
`ES2023` lib. `SseStream` therefore installs it behind a `typeof Symbol.asyncDispose === 'symbol'`
guard, matching `Response` (`HTTP-38`). Recorded at §I3; it becomes an unconditional `implements
AsyncDisposable` when the floor moves past 20.4. Note that Phase 6c's `Page` resolves the same question
the other way — see that changeset.
- **`MapperOutcome<T>` is a sibling of Phase 4b's `Outcome<T>`, not a third variant on it.** `Outcome<T>`
is a two-branch success/failure union threaded through the recovery chain; widening it with `skip` and
`done` would force every `fold` call site in `src/recovery/` to handle variants that can never occur
there. What `sdk-design-nodejs/07` §7.2 asks to reuse is the *idiom* — a `kind`-discriminated union over
frozen literals.

Limits worth knowing at the call site:

- **The line cap is opt-in and off by default** (`SSE-19`), matching the reference's own absence of a cap.
Set `maxLineBytes` to bound memory against a server that never sends a terminator; exceeding it raises
`SseLineTooLongError`, which carries `limitBytes` as a field so a log aggregator indexes it without
parsing the message.
- **`signal` adds a trigger, not a code path.** Aborting closes the stream, which is all the cancellation
a pull-based reader needs: an iterator sitting *between* pulls ends cleanly (`SSE-27`), and one blocked
*in* a read surfaces an `IoError` (`SSE-31`). Both paths release the owned resource exactly once.
- **A release failure on a clean terminal path is swallowed and reported out-of-band** (`SSE-30`), because
throwing would discard events already delivered. `onReleaseFailure` receives it and defaults to a no-op;
Phase 7 wires a real `Logger` there without reshaping the class. An explicit `close()` still propagates.
- **A bodyless response is rejected rather than yielding an empty stream** (`SSE-32`). It is a server or
caller mistake, and silently producing zero events would hide it behind a successful-looking loop that
does nothing.
- **`SSE-41`'s reactive `Observable` view is not here.** It is a MAY, and the roadmap scopes §18's
async-runtime adapters to Phase 8b (`@dexpace/rx`). Deferral recorded at §I1. `SSE-21`'s hash-equality
clause has no JavaScript analogue; value equality ships as `sseEventsEqual()` (§I4).
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ jobs:
- name: SEAM-1 zero-dependency check
run: bun run verify:seam-1

- name: Verify SSE-37/SSE-38 (no serde dependency, no reconnect path in core SSE)
run: bun run verify:sse-37

- name: Runtime-floor consistency check
run: bun run verify:runtime-floor

Expand Down
Loading
Loading