From 3946e7c66ecc0fc59a7cd669706f1975206c272d Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Mon, 10 Aug 2026 14:34:39 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(core):=20add=20seam=20foundations=20?= =?UTF-8?q?=E2=80=94=20Transport,=20buildRequest,=20DexpaceError=20root=20?= =?UTF-8?q?(SEAM-11/12/14/16/17/19/26/27/30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the Phase 2 seam contracts: the Transport interface with composeSignal/isTimeoutSignal and CancellationError, the operation-input projection (OperationDescriptor, buildRequest, OperationAssemblyError), and an internal-only provisional Serde kept out of the public barrel so Phase 6's type-witness rework stays non-breaking. Retrofits Phase 1 with a shared encodeRfc3986Component (HTTP-29) and DexpaceError as the taxonomy root above DomainModelError, and adds a node-floor-conformance CI job that runs the built artifact on Node 18.17.0. Also retrofits a Deviation Ledger section onto the Phase 2 spec and widens Phase 10's intake to row-level checks, so the dot-segment SEAM-27 rejection and Phase 9's own ledger rows can't be dropped at reconciliation. --- .changeset/seam-foundations.md | 7 + .github/workflows/ci.yml | 23 +++ ...07-23-phase2-seam-foundations-checklist.md | 2 +- ...-07-28-phase10-deviation-reconciliation.md | 39 ++-- ...26-07-23-phase2-seam-foundations-design.md | 12 ++ ...phase10-deviation-reconciliation-design.md | 35 ++-- eslint.config.js | 30 +++ package.json | 3 +- packages/core/etc/core.api.md | 41 +++- packages/core/src/http/errors.test.ts | 16 ++ packages/core/src/http/errors.ts | 22 +- packages/core/src/http/index.ts | 1 + packages/core/src/http/query-params.ts | 12 +- packages/core/src/http/rfc3986.test.ts | 29 +++ packages/core/src/http/rfc3986.ts | 17 ++ packages/core/src/index.ts | 12 ++ packages/core/src/seams/index.ts | 13 ++ packages/core/src/seams/operation.test.ts | 164 +++++++++++++++ packages/core/src/seams/operation.ts | 188 ++++++++++++++++++ packages/core/src/seams/serde.test.ts | 33 +++ packages/core/src/seams/serde.ts | 15 ++ packages/core/src/seams/transport.test.ts | 57 ++++++ packages/core/src/seams/transport.ts | 123 ++++++++++++ scripts/verify-node-floor.mjs | 26 +++ tsconfig.base.json | 3 +- 25 files changed, 877 insertions(+), 46 deletions(-) create mode 100644 .changeset/seam-foundations.md create mode 100644 packages/core/src/http/rfc3986.test.ts create mode 100644 packages/core/src/http/rfc3986.ts create mode 100644 packages/core/src/seams/index.ts create mode 100644 packages/core/src/seams/operation.test.ts create mode 100644 packages/core/src/seams/operation.ts create mode 100644 packages/core/src/seams/serde.test.ts create mode 100644 packages/core/src/seams/serde.ts create mode 100644 packages/core/src/seams/transport.test.ts create mode 100644 packages/core/src/seams/transport.ts create mode 100644 scripts/verify-node-floor.mjs diff --git a/.changeset/seam-foundations.md b/.changeset/seam-foundations.md new file mode 100644 index 0000000..ee18c98 --- /dev/null +++ b/.changeset/seam-foundations.md @@ -0,0 +1,7 @@ +--- +"@dexpace/core": minor +--- + +Add the seam foundations: the `Transport` contract with its `composeSignal`/`isTimeoutSignal` cancellation helpers and `CancellationError`, the operation-input projection (`OperationDescriptor`, `buildRequest`, `OperationAssemblyError`), and `DexpaceError` as the new root of the error taxonomy above `DomainModelError`. + +`DomainModelError` now extends `DexpaceError` instead of `Error`. This is additive — every existing leaf keeps its parent, its behavior, and its `instanceof DomainModelError` narrowing. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afa1c8a..899ac6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,3 +55,26 @@ jobs: - name: Dependency audit run: bun run audit + + node-floor-conformance: + needs: ci + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + + - name: Install (frozen lockfile) + run: bun install --frozen-lockfile + + - name: Build + run: bun run build + + - uses: actions/setup-node@v4 + with: + node-version: 18.17.0 + + - name: Verify the built artifact against the declared minimum Node version (NFR-10/NFR-17) + run: node scripts/verify-node-floor.mjs diff --git a/docs/superpowers/plans/2026-07-23-phase2-seam-foundations-checklist.md b/docs/superpowers/plans/2026-07-23-phase2-seam-foundations-checklist.md index cfce684..b0ab1f1 100644 --- a/docs/superpowers/plans/2026-07-23-phase2-seam-foundations-checklist.md +++ b/docs/superpowers/plans/2026-07-23-phase2-seam-foundations-checklist.md @@ -33,7 +33,7 @@ phase — ⏳ Deferred (named reason) — N/A. | ID | Level | Requirement gist | Status | Where | |---|---|---|---|---| | SEAM-26 | MUST | `OperationDescriptor`: method + path required, four projections default to empty | ✅ | Task 6, direct conformance test (parameterless GET) | -| SEAM-27 | MUST | `buildRequest()` encoding + base-URL composition rules | ✅ | Task 6 — worked example, trailing-slash normalization, empty-path no-op, base-query preservation, fragment/malformed rejection, missing-placeholder error, and the path-param `/`-encoding property test, one test per conformance note | +| SEAM-27 | MUST | `buildRequest()` encoding + base-URL composition rules | ✅ | Task 6 — worked example, trailing-slash normalization, empty-path no-op, base-query preservation, fragment/malformed rejection, missing-placeholder error, and the path-param `/`-encoding property test, one test per conformance note. **One deviation, stricter than the requirement's letter:** a `.`/`..` path-param value is rejected rather than encoded, because no encoding survives the WHATWG URL parser's dot-segment normalization — recorded in the design doc's `## Deviation Ledger (for Phase 10)` section, which Phase 10 folds into `docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md` | ## Cross-cutting diff --git a/docs/superpowers/plans/2026-07-28-phase10-deviation-reconciliation.md b/docs/superpowers/plans/2026-07-28-phase10-deviation-reconciliation.md index 244a524..fb8d55b 100644 --- a/docs/superpowers/plans/2026-07-28-phase10-deviation-reconciliation.md +++ b/docs/superpowers/plans/2026-07-28-phase10-deviation-reconciliation.md @@ -21,7 +21,7 @@ landed intact, plus a manual completeness cross-check against every phase's own **Prerequisite:** Phases 0 through 8b's specs and plans exist exactly as committed on `main` as of this plan's writing (`2026-07-28`). This plan does not require any phase's *code* to exist — Phase 10 audits documents, not a running SDK (see the design doc's "Why This Phase Doesn't Need Phase 9 to Run First" section). It reads, but does -not modify, every Phase 3a-8b spec and the 5c/6a/6b/6c plans' Deviation Ledger sections. +not modify, every Phase 2 and 3a-8b spec and the 5c/6a/6b/6c plans' Deviation Ledger sections. ## Global Constraints @@ -56,9 +56,9 @@ No new files. Two existing files modified. - (staging only — no file written this task; Task 2 applies this text) **Interfaces:** -- Consumes: every Phase 3a-8b spec's `## Deviation Ledger (for Phase 10)` section; 5c/6a/6b/6c plans' `## +- Consumes: every Phase 2 and 3a-8b spec's `## Deviation Ledger (for Phase 10)` section; 5c/6a/6b/6c plans' `## Deviation Ledger Additions (for Phase 10)` sections; Phase 1's plan (`RequestConditions.applyTo` ETag note); - Phase 2's design (`SEAM-5`-`10`/`SEAM-18` naming); the current 12-item text of + the current 12-item text of `docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`. - Produces: the exact Markdown body Task 2 writes into that file. @@ -400,16 +400,24 @@ silently skips them and under-verifies. - (read-only verification pass, no modifications) **Interfaces:** -- Consumes: every Phase 3a-8b spec's Deviation Ledger section, the 5c/6a/6b/6c plans' Deviation Ledger Additions, - Task 2's rewritten §10. +- Consumes: every Phase 2 and 3a-8b spec's Deviation Ledger section, the 5c/6a/6b/6c plans' Deviation Ledger + Additions, Task 2's rewritten §10. - Produces: a pass/fail judgment — if this fails, return to Task 1 and add the missing material before considering the phase done. -- [ ] **Step 1: Re-open every one of these 15 files and confirm each phase named below has at least one - corresponding sentence in the rewritten §10** (this list is exhaustive — every phase from 3a through 8b that has - a Deviation Ledger section): +- [ ] **Step 1: Re-open every one of these 17 files and confirm every *row* of each one's ledger table has a + corresponding sentence in the rewritten §10** (this list is exhaustive — every phase from 2 through 9 that has + a Deviation Ledger section; regenerate it with + `grep -rln '^## Deviation Ledger (for Phase 10)' docs/superpowers/specs/` rather than trusting this transcription): + + Check rows, not phases. A phase-label grep is not sufficient evidence here: several phases are cited by many + items, so `Phase 2` (or `Phase 5a`, or `Phase 8a`) appearing in §10 proves only that *something* from that + phase survived — it cannot detect one dropped row from a phase that is already cited elsewhere. That is exactly + how Phase 2's dot-segment `SEAM-27` rejection went unrecorded until it was caught by hand during Phase 2's own + validation. Walk each table row by row. ``` +2 docs/superpowers/specs/2026-07-23-phase2-seam-foundations-design.md 3a docs/superpowers/specs/2026-07-24-phase3a-io-contracts-design.md 3b docs/superpowers/specs/2026-07-25-phase3b-body-lifecycle-design.md 4a docs/superpowers/specs/2026-07-25-phase4a-execution-context-design.md @@ -425,17 +433,24 @@ silently skips them and under-verifies. 7b docs/superpowers/specs/2026-07-28-phase7b-observability-design.md 8a docs/superpowers/specs/2026-07-28-phase8a-transport-design.md 8b docs/superpowers/specs/2026-07-28-phase8b-async-runtime-design.md +9 docs/superpowers/specs/2026-07-28-phase9-cross-cutting-conformance-design.md ``` - Expected: every phase's citation `(Phase Xy)` appears at least once in + Expected, primary: every ledger table row across those 16 files is either represented by a sentence in the + rewritten §10 or explicitly listed as legitimately excluded (see the note below on 4a's and 7b's + implementation-detail entries). A row that is neither is a dropped deviation — return to Task 1. + + Expected, secondary (necessary, not sufficient): every phase's citation `(Phase Xy)` appears at least once in `docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md` — spot-check with `grep -o 'Phase [0-9][a-z]\?' docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md | sort -u` and confirm the output set is `{Phase 0, Phase 1, Phase 2, Phase 3a, Phase 3b, Phase 4a, Phase 4b, Phase 4c, Phase 5a, Phase 5b, Phase 5c, Phase 6a, Phase 6b, Phase 6c, Phase 7a, Phase 7b, Phase 8a, Phase 8b, Phase 9}` — - every phase from 3a through 8b now cited at least once, including 4a (execution-context store/key collapse, + every phase from 2 through 8b now cited at least once, including 4a (execution-context store/key collapse, folded into Item 1) and 7b (`AsyncLocalStorage` propagation, also folded into Item 1), which an earlier draft - of this plan wrongly left uncited. `Phase 9` appears only as the unblock trigger for Item 12's two open - questions and Item 10's shrink-test origin note, not as a phase with its own deviation entry. Note: 4a's and + of this plan wrongly left uncited. `Phase 9` is cited both as the unblock trigger for Item 12's two open + questions and Item 10's shrink-test origin note **and** as a phase with its own two-row ledger (`XCUT-23` + satisfied vacuously; `NFR-8` shipping nothing) — an earlier draft of this plan asserted it had no deviation + entry of its own, which its design doc contradicts. Note: 4a's and 7b's *other* ledger entries (Symbol() call-key ergonomics, `ContextInit` options-object shape, `contextsEqual()` omission, the retry/redirect logging vocabulary gap, the `Tracer`/`Span` structural-subset choice) are legitimately excluded from §10 — they're implementation-detail choices made where the spec was silent, not diff --git a/docs/superpowers/specs/2026-07-23-phase2-seam-foundations-design.md b/docs/superpowers/specs/2026-07-23-phase2-seam-foundations-design.md index 0ec17fd..37dcbd1 100644 --- a/docs/superpowers/specs/2026-07-23-phase2-seam-foundations-design.md +++ b/docs/superpowers/specs/2026-07-23-phase2-seam-foundations-design.md @@ -280,6 +280,18 @@ Since `DomainModelError` lives in `http/errors.ts` (Phase 1) and is already expo `DexpaceError` goes in the same file and joins the barrel alongside it — an additive API change, so it needs a changeset but not a major bump. +## Deviation Ledger (for Phase 10) + +Phase 2 predates the per-phase ledger convention that specs 3a-8b follow; this section is the retrofit, so Phase +10's reconciliation reads Phase 2 the same way it reads every other phase instead of pulling two items out of the +prose above by name. + +| Deviation | Against | Reason | +|---|---|---| +| A `.`/`..` path-parameter value is rejected (`OperationAssemblyError`), not encoded | `SEAM-27` ("percent-encoded as single path segments") | Both are RFC 3986 *unreserved*, so encoding leaves them untouched, and the WHATWG URL parser folds `%2E` back to `.` during dot-segment normalization — no encoding keeps them as one literal segment. Forwarding `..` lets a path value rewrite the path (`/things/..` resolves to `/`), the injection class the requirement's own parenthetical exists to stop. Stricter than the requirement's letter, in service of its intent; no other value is affected | +| Discovery / registration / conflict-resolution machinery is never built | `SEAM-5`-`SEAM-10` | Nothing in this port is pluggable enough to need discovering: one `Transport` shape, one async primitive, and Web Streams as the platform's own byte-stream answer rather than a third-party library to keep out of core (`sdk-design/03` §3.1, §3.5) | +| The sync↔async bridge is never built; only its options-threading clause survives | `SEAM-18` | A bridge connects two transport seams; `SEAM-11`/`SEAM-16` collapse to one here, so there is nothing on either bank. Every bridge-specific obligation presupposes a blocking transport Node cannot idiomatically have. The one non-bridge clause — per-call options threaded through, never dropped — survives as an ordinary `Transport.send()` obligation, not a deviation | + ## Testing Phase 2 ships interfaces plus three pure functions. Only the pure functions are behaviorally testable; every diff --git a/docs/superpowers/specs/2026-07-28-phase10-deviation-reconciliation-design.md b/docs/superpowers/specs/2026-07-28-phase10-deviation-reconciliation-design.md index 16b88fe..cbc6622 100644 --- a/docs/superpowers/specs/2026-07-28-phase10-deviation-reconciliation-design.md +++ b/docs/superpowers/specs/2026-07-28-phase10-deviation-reconciliation-design.md @@ -13,9 +13,9 @@ roadmap's Deferred Items Log already targets at Phase 10 by name, and settles on tension a Phase 1 plan explicitly deferred here. **Scope is documents, not code.** Phase 10 ships no package — the roadmap table lists it as `— (review only)`. -Every phase spec from 3a through 8b already carries its own `## Deviation Ledger (for Phase 10)` section (Phases -1 and 2 predate that convention but each has an equivalent flagged item, see below); those sections are complete, -already-reasoned raw material. Phase 10's job is consolidation and cross-referencing, not new investigation — +Every phase spec from 3a through 8b already carries its own `## Deviation Ledger (for Phase 10)` section, and +Phase 2's design carries a retrofitted one (Phase 1 still predates the convention but has an equivalent flagged +item, see below); those sections are complete, already-reasoned raw material. Phase 10's job is consolidation and cross-referencing, not new investigation — with two exceptions. First, two items (`NFR-12`, `NFR-16`) are soft gaps that need a real build/publish artifact to verify, which doesn't exist in this docs-only repository state yet; Phase 10 cannot close those, and records them as open with an explicit unblock trigger rather than papering over them. Second, four items (two redirect/ @@ -26,9 +26,9 @@ conformance only, and will never produce that evidence. Phase 10 decides those f nothing in this phase's own scope is left waiting on Phase 9. **Governing documents:** `docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md` (the -document this phase rewrites), the roadmap's own Deferred Items Log, and every Phase 3a–8b spec's Deviation -Ledger section plus the handful of plan-level "Deviation Ledger Additions" sections (5c, 6a, 6b, 6c) that amend -their design doc's ledger with plan-time corrections. +document this phase rewrites), the roadmap's own Deferred Items Log, and every Phase 2, 3a–8b, and 9 spec's +Deviation Ledger section plus the handful of plan-level "Deviation Ledger Additions" sections (5c, 6a, 6b, 6c) +that amend their design doc's ledger with plan-time corrections. ## Why This Phase Doesn't Need Phase 9 to Run First @@ -63,10 +63,13 @@ rather keep the original alongside the reconciled version. ## Reconciliation Process -1. **Collect.** Pull every phase's `## Deviation Ledger (for Phase 10)` section (specs 3a-8b) plus every plan's - `## Deviation Ledger Additions (for Phase 10)` section (5c, 6a, 6b, 6c) verbatim. Phases 1 and 2 predate the - convention: Phase 1's plan flags the HTTP-18/HTTP-50 ETag tension inline (not in a named section); Phase 2's - design names `SEAM-5`-`SEAM-10` and `SEAM-18` directly. Both are pulled in by name. +1. **Collect.** Pull every phase's `## Deviation Ledger (for Phase 10)` section (specs 2, 3a-8b, and 9) plus every + plan's `## Deviation Ledger Additions (for Phase 10)` section (5c, 6a, 6b, 6c) verbatim. Phase 2 originally + predated the convention and was pulled in by name for `SEAM-5`-`SEAM-10` and `SEAM-18` only; its design now + carries a retrofitted ledger section covering those two plus the dot-segment path-parameter rejection + (`SEAM-27`) that the by-name pull would have missed, so Phase 2 is collected like any other phase and needs no + special case. **Phase 1 still does:** its plan flags the HTTP-18/HTTP-50 ETag tension inline, not in a named + section, and is pulled in by name. 2. **Cross-reference against the original 12.** Each of the 12 items in the current §10 either: (a) is confirmed as-built with no change, (b) is confirmed but needs expansion because later phases added detail the prediction didn't anticipate, or (c) needs correction because a later phase's actual mechanism diverged from @@ -219,12 +222,14 @@ passing it through. ## Verification / Completeness Check Before the rewritten §10 is considered done, confirm every source is accounted for: -- Every phase spec 3a-8b's `## Deviation Ledger (for Phase 10)` section has at least one entry represented in - the reconciled ledger (cross-check against the extraction performed for this design — 15 of 15 sections - present and non-empty). +- Every phase spec 2, 3a-8b, and 9's `## Deviation Ledger (for Phase 10)` section has **every row** represented in + the reconciled ledger, or explicitly listed as legitimately excluded (cross-check against the extraction + performed for this design — 17 of 17 sections present and non-empty). Row-level, not section-level: a phase + cited by several items can still have one row silently dropped, which is how Phase 2's dot-segment `SEAM-27` + rejection was missed until Phase 2's own validation caught it by hand. - Every plan-level `## Deviation Ledger Additions (for Phase 10)` section (5c, 6a, 6b, 6c) is folded in. -- Phase 1's ETag flag and Phase 2's `SEAM-5`-`10`/`SEAM-18` naming (pre-dating the ledger-section convention) are - represented. +- Phase 1's ETag flag (still pre-dating the ledger-section convention, still pulled in by name) is represented. + Phase 2 no longer needs a by-name pull — its design carries a retrofitted ledger section. - Every roadmap Deferred Items Log row naming "Phase 10" (`NFR-8`, `NFR-12`, `NFR-16`, the `SEAM-5`-`10` and `SEAM-18` rows, plus the four rows the 2026-07-28 update retargeted here from a Phase 9 sweep that turned out never to run them — redirect-predicate scope, Basic/Digest preemptive-stamp reading, diff --git a/eslint.config.js b/eslint.config.js index 2189176..b45f534 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -60,6 +60,36 @@ export default tseslint.config( '@typescript-eslint/explicit-module-boundary-types': 'error', // Every `eslint-disable` must carry a `-- reason` (NFR-7's documented-exception clause). '@eslint-community/eslint-comments/require-description': 'error', + // lib.dom (added for the seam surface's AbortSignal/AbortController/DOMException) declares + // global Request/Response/Headers/window/document. A core file that forgets its own import no + // longer fails to compile — it silently type-checks against the DOM global instead. An + // imported binding shadows the global, so correctly-importing files are unaffected. + 'no-restricted-globals': [ + 'error', + { + name: 'Request', + message: + 'lib.dom global — import Request from src/http/request.js instead.', + }, + { + name: 'Response', + message: + 'lib.dom global — import Response from src/http/response.js instead.', + }, + { + name: 'Headers', + message: + 'lib.dom global — import Headers from src/http/headers.js instead.', + }, + { + name: 'window', + message: 'Browser-only global; @dexpace/core is runtime-agnostic.', + }, + { + name: 'document', + message: 'Browser-only global; @dexpace/core is runtime-agnostic.', + }, + ], }, }, ); diff --git a/package.json b/package.json index 07aaf7b..6aeafe9 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "audit": "bun audit --audit-level=high --prod", "verify:dual-consumption": "node scripts/verify-dual-consumption.mjs", "verify:seam-1": "node scripts/verify-seam-1.mjs", - "verify:runtime-floor": "node scripts/verify-runtime-floor.mjs" + "verify:runtime-floor": "node scripts/verify-runtime-floor.mjs", + "verify:node-floor": "node scripts/verify-node-floor.mjs" } } diff --git a/packages/core/etc/core.api.md b/packages/core/etc/core.api.md index 407522c..ac14728 100644 --- a/packages/core/etc/core.api.md +++ b/packages/core/etc/core.api.md @@ -10,10 +10,24 @@ export interface Builder { } // @public -export class DomainModelError extends Error { +export function buildRequest(baseUrl: string | URL, operation: OperationDescriptor): Request_2; + +// @public +export class CancellationError extends DexpaceError { +} + +// @public +export function composeSignal(userSignal?: AbortSignal, timeoutMs?: number): AbortSignal | undefined; + +// @public +export class DexpaceError extends Error { constructor(message: string, options?: ErrorOptions); } +// @public +export class DomainModelError extends DexpaceError { +} + // @public export class ETag { static readonly ANY: ETag; @@ -82,6 +96,9 @@ export class HttpRange { export class HttpRangeValidationError extends DomainModelError { } +// @public +export function isTimeoutSignal(signal: AbortSignal): boolean; + // @public export class MediaType { get charset(): string | undefined; @@ -102,6 +119,22 @@ export class MediaTypeParseError extends DomainModelError { // @public export type Method = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE' | 'PATCH'; +// @public +export class OperationAssemblyError extends DexpaceError { + constructor(message: string, parameterName: string); + readonly parameterName: string; +} + +// @public +export interface OperationDescriptor { + readonly body?: unknown; + readonly headers?: Headers_2 | undefined; + readonly method: Method; + readonly pathParams?: Readonly> | undefined; + readonly pathTemplate: string; + readonly query?: QueryParams | undefined; +} + // @public export class Protocol { equals(other: Protocol): boolean; @@ -250,6 +283,12 @@ export class Status { static recognized(code: number): Status | undefined; } +// @public +export interface Transport { + close(): Promise; + send(request: Request_2, options?: RequestOptions, signal?: AbortSignal): Promise; +} + // @public export class UrlConstructionError extends DomainModelError { } diff --git a/packages/core/src/http/errors.test.ts b/packages/core/src/http/errors.test.ts index 1481fbd..6e799ad 100644 --- a/packages/core/src/http/errors.test.ts +++ b/packages/core/src/http/errors.test.ts @@ -3,6 +3,8 @@ // Exercises: HTTP-4 (field-named errors), HTTP-20 (no value echo, escaped name) import {describe, expect, test} from 'bun:test'; import { + DexpaceError, + DomainModelError, RequiredFieldError, HeaderValidationError, toError, @@ -52,3 +54,17 @@ describe('RequestBodyNotAllowedError', () => { expect(error.message).toContain('GET'); }); }); + +// Exercises: the Phase 2 retrofit — DexpaceError as the taxonomy root above DomainModelError +describe('DexpaceError', () => { + test('sets name to the concrete subclass name', () => { + const error = new DexpaceError('boom'); + expect(error.name).toBe('DexpaceError'); + }); + + test('DomainModelError is a DexpaceError, and every existing leaf still narrows by DomainModelError', () => { + const error = new RequiredFieldError('url'); + expect(error).toBeInstanceOf(DomainModelError); + expect(error).toBeInstanceOf(DexpaceError); + }); +}); diff --git a/packages/core/src/http/errors.ts b/packages/core/src/http/errors.ts index 33f3ea6..1bbee1a 100644 --- a/packages/core/src/http/errors.ts +++ b/packages/core/src/http/errors.ts @@ -1,15 +1,13 @@ // SPDX-License-Identifier: MIT // packages/core/src/http/errors.ts /** - * The root of every error the HTTP domain model throws. - * - * Catch this to handle any construction, validation, or parse failure from the model uniformly; - * catch a leaf subclass to distinguish a specific failure. Every subclass sets `name` to its own - * class name, and wrap-and-rethrow always passes `{cause}`. + * The root of the SDK's entire error taxonomy — the "anything this SDK threw" catch-all. Every + * error the SDK raises, domain-model or otherwise, extends this class. Every subclass sets `name` + * to its own class name, and wrap-and-rethrow always passes `{cause}`. * * @public */ -export class DomainModelError extends Error { +export class DexpaceError extends Error { /** * @param message - the human-readable failure description. * @param options - standard error options; pass `{cause}` when wrapping a caught error. @@ -20,6 +18,18 @@ export class DomainModelError extends Error { } } +/** + * The root of every error the HTTP domain model throws. + * + * Catch this to handle any construction, validation, or parse failure from the model uniformly; + * catch a leaf subclass to distinguish a specific failure. A sibling of the seam layer's + * {@link DexpaceError}-rooted errors (`CancellationError`, `OperationAssemblyError`) — a cancelled + * transport call or an unassembled operation is not itself a domain-model construction failure. + * + * @public + */ +export class DomainModelError extends DexpaceError {} + // Narrows a caught `unknown` into an Error (styleguide 8.4). Defined once, imported everywhere a caught // value becomes a `cause`. Must never itself throw from inside a catch — String() can throw on a // null-prototype object or a hostile toString, hence the inner try. diff --git a/packages/core/src/http/index.ts b/packages/core/src/http/index.ts index 05aaaa0..4ae37a7 100644 --- a/packages/core/src/http/index.ts +++ b/packages/core/src/http/index.ts @@ -6,6 +6,7 @@ // caller yet (api-design ch10: helpers stay unexported until an outside caller genuinely needs them). export type {Builder} from './builder.js'; export { + DexpaceError, DomainModelError, RequiredFieldError, HeaderValidationError, diff --git a/packages/core/src/http/query-params.ts b/packages/core/src/http/query-params.ts index 7c6873e..0510f3f 100644 --- a/packages/core/src/http/query-params.ts +++ b/packages/core/src/http/query-params.ts @@ -1,13 +1,7 @@ // SPDX-License-Identifier: MIT // packages/core/src/http/query-params.ts import type {Builder} from './builder.js'; - -function percentEncodeComponent(value: string): string { - return encodeURIComponent(value).replace( - /[!*'()]/g, - c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, - ); -} +import {encodeRfc3986Component} from './rfc3986.js'; function safeDecodeComponent(value: string): string { try { @@ -152,9 +146,9 @@ export class QueryParams { encode(): string { const parts: string[] = []; for (const name of this.#insertionOrder) { - const encodedName = percentEncodeComponent(name); + const encodedName = encodeRfc3986Component(name); for (const value of this.#valuesByName.get(name) ?? []) { - parts.push(`${encodedName}=${percentEncodeComponent(value)}`); + parts.push(`${encodedName}=${encodeRfc3986Component(value)}`); } } return parts.join('&'); diff --git a/packages/core/src/http/rfc3986.test.ts b/packages/core/src/http/rfc3986.test.ts new file mode 100644 index 0000000..090474e --- /dev/null +++ b/packages/core/src/http/rfc3986.test.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/rfc3986.test.ts +// Exercises: HTTP-29 (RFC 3986 percent-encoding, not application/x-www-form-urlencoded) — the shared encoder +// used by QueryParams (HTTP-29/32) and buildRequest's path-segment encoding (SEAM-27) +import {describe, expect, test} from 'bun:test'; +import {encodeRfc3986Component} from './rfc3986.js'; + +describe('encodeRfc3986Component', () => { + test('encodes space as %20, never +', () => { + expect(encodeRfc3986Component('a b')).toBe('a%20b'); + }); + + test('encodes a literal + as %2B', () => { + expect(encodeRfc3986Component('c+d')).toBe('c%2Bd'); + }); + + test('encodes / as %2F', () => { + expect(encodeRfc3986Component('a/b')).toBe('a%2Fb'); + }); + + test("encodes the characters encodeURIComponent leaves unescaped but RFC 3986 doesn't: ! * ' ( )", () => { + expect(encodeRfc3986Component("!*'()")).toBe('%21%2A%27%28%29'); + }); + + test('leaves the unreserved set untouched: A-Z a-z 0-9 - . _ ~', () => { + const unreserved = 'AZaz09-._~'; + expect(encodeRfc3986Component(unreserved)).toBe(unreserved); + }); +}); diff --git a/packages/core/src/http/rfc3986.ts b/packages/core/src/http/rfc3986.ts new file mode 100644 index 0000000..a2d6db5 --- /dev/null +++ b/packages/core/src/http/rfc3986.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/rfc3986.ts + +/** + * Percent-encodes a single URL component per RFC 3986, patching `encodeURIComponent`'s divergence: + * `encodeURIComponent` leaves `! * ' ( )` unescaped, but none of them are in RFC 3986's unreserved + * set (HTTP-29). + * + * @param value - the raw component value. + * @returns the percent-encoded component. + */ +export function encodeRfc3986Component(value: string): string { + return encodeURIComponent(value).replace( + /[!*'()]/g, + c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index df1ff17..9ae93a0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,3 +12,15 @@ * @packageDocumentation */ export * from './http/index.js'; + +// Deliberately NOT `export * from './seams/index.js';` — that barrel also carries the internal-only, +// provisional Serde (SEAM-21 will reshape it in Phase 6). Naming each public export here instead keeps +// Serde unreachable from the package's public entry point and out of the api-extractor surface. +export type {Transport} from './seams/transport.js'; +export { + composeSignal, + isTimeoutSignal, + CancellationError, +} from './seams/transport.js'; +export type {OperationDescriptor} from './seams/operation.js'; +export {buildRequest, OperationAssemblyError} from './seams/operation.js'; diff --git a/packages/core/src/seams/index.ts b/packages/core/src/seams/index.ts new file mode 100644 index 0000000..0b07a1c --- /dev/null +++ b/packages/core/src/seams/index.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/seams/index.ts +// Internal-facing seams barrel — includes Serde, unlike the package's public entry point +// (packages/core/src/index.ts), which deliberately omits it (SEAM-21 is deferred to Phase 6). +export type {Transport} from './transport.js'; +export { + composeSignal, + isTimeoutSignal, + CancellationError, +} from './transport.js'; +export type {Serde} from './serde.js'; +export type {OperationDescriptor} from './operation.js'; +export {buildRequest, OperationAssemblyError} from './operation.js'; diff --git a/packages/core/src/seams/operation.test.ts b/packages/core/src/seams/operation.test.ts new file mode 100644 index 0000000..d166bc1 --- /dev/null +++ b/packages/core/src/seams/operation.test.ts @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/seams/operation.test.ts +// Exercises: SEAM-26 (the four projections default to empty), SEAM-27 (buildRequest's encoding and base-URL +// composition rules), reusing HTTP-29's encodeRfc3986Component for path-segment encoding. +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import { + buildRequest, + OperationAssemblyError, + type OperationDescriptor, +} from './operation.js'; +import {UrlConstructionError} from '../http/errors.js'; +import {QueryParams} from '../http/query-params.js'; +import {Headers} from '../http/headers.js'; + +describe('SEAM-26: a parameterless GET overriding only method+path', () => { + test('assembles a well-formed request with empty headers and no query', () => { + const request = buildRequest('https://api.example.com', { + method: 'GET', + pathTemplate: '/pets', + }); + expect(request.method).toBe('GET'); + expect(request.url.href).toBe('https://api.example.com/pets'); + expect(request.headers.names()).toEqual([]); + expect(request.body).toBeUndefined(); + }); +}); + +describe('SEAM-27: worked example', () => { + test('host/c?sig=.. + /pets assembles to host/c/pets?sig=..&', () => { + const operation: OperationDescriptor = { + method: 'GET', + pathTemplate: '/pets', + query: QueryParams.newBuilder().add('limit', '10').build(), + }; + const request = buildRequest('https://host/c?sig=abc', operation); + expect(request.url.pathname).toBe('/c/pets'); + expect(request.url.search).toBe('?sig=abc&limit=10'); + }); +}); + +describe('SEAM-27: base-URL composition rules', () => { + test('a trailing slash on the base normalizes to one separator', () => { + const request = buildRequest('https://host/c/', { + method: 'GET', + pathTemplate: '/pets', + }); + expect(request.url.pathname).toBe('/c/pets'); + }); + + test('an empty operation path leaves the base untouched', () => { + const request = buildRequest('https://host/c', { + method: 'GET', + pathTemplate: '', + }); + expect(request.url.pathname).toBe('/c'); + }); + + test('an existing base query is preserved with the operation query appended after it', () => { + const operation: OperationDescriptor = { + method: 'GET', + pathTemplate: '/pets', + query: QueryParams.newBuilder().add('a', '1').build(), + }; + const request = buildRequest('https://host/c?existing=yes', operation); + expect(request.url.search).toBe('?existing=yes&a=1'); + }); + + test("a dangling separator on the base query is dropped before appending (SEAM-27's parenthetical)", () => { + const operation: OperationDescriptor = { + method: 'GET', + pathTemplate: '/pets', + query: QueryParams.newBuilder().add('a', '1').build(), + }; + const request = buildRequest('https://host/c?existing=yes&', operation); + expect(request.url.search).toBe('?existing=yes&a=1'); + }); + + test('a fragment-bearing base is rejected', () => { + expect(() => + buildRequest('https://host/c#frag', { + method: 'GET', + pathTemplate: '/pets', + }), + ).toThrow(UrlConstructionError); + }); + + test('a malformed base is rejected', () => { + expect(() => + buildRequest('::bad', {method: 'GET', pathTemplate: '/pets'}), + ).toThrow(UrlConstructionError); + }); + + test('a missing placeholder value throws OperationAssemblyError', () => { + expect(() => + buildRequest('https://host', {method: 'GET', pathTemplate: '/pets/{id}'}), + ).toThrow(OperationAssemblyError); + }); +}); + +describe('operation headers and body projections are threaded through', () => { + test('supplied headers and body appear on the built request', () => { + const headers = Headers.newBuilder().add('X-Trace', 'abc').build(); + const request = buildRequest('https://host', { + method: 'POST', + pathTemplate: '/pets', + headers, + body: {name: 'Fido'}, + }); + expect(request.headers.get('x-trace')).toBe('abc'); + expect(request.body).toEqual({name: 'Fido'}); + }); +}); + +describe('SEAM-27: dot-segment path-param values are rejected, not silently normalized away', () => { + // "." and ".." survive RFC 3986 encoding (both are unreserved), and the WHATWG URL parser treats "%2E" the + // same as "." when it normalizes dot segments — so no encoding can keep them literal. A value of ".." would + // otherwise rewrite the path (/things/.. → /), the same injection class SEAM-27's %2F rule exists to stop. + test('a path-param value of "." throws OperationAssemblyError', () => { + expect(() => + buildRequest('https://host', { + method: 'GET', + pathTemplate: '/things/{id}', + pathParams: {id: '.'}, + }), + ).toThrow(OperationAssemblyError); + }); + + test('a path-param value of ".." throws OperationAssemblyError', () => { + expect(() => + buildRequest('https://host', { + method: 'GET', + pathTemplate: '/things/{id}', + pathParams: {id: '..'}, + }), + ).toThrow(OperationAssemblyError); + }); +}); + +describe('a path-param value containing / is encoded, not split (property)', () => { + test('holds for arbitrary generated path-param values', () => { + fc.assert( + fc.property( + // "." and ".." are excluded here because buildRequest rejects them by design — the two example tests + // above pin that behavior; every other string must survive as exactly one path segment. + fc + .string({minLength: 1, maxLength: 20}) + .filter(s => s !== '.' && s !== '..'), + value => { + const request = buildRequest('https://host', { + method: 'GET', + pathTemplate: '/things/{id}', + pathParams: {id: value}, + }); + const segments = request.url.pathname + .split('/') + .filter(segment => segment !== ''); + expect(segments.length).toBe(2); + expect(segments[0]).toBe('things'); + }, + ), + ); + }); +}); diff --git a/packages/core/src/seams/operation.ts b/packages/core/src/seams/operation.ts new file mode 100644 index 0000000..cb05c5a --- /dev/null +++ b/packages/core/src/seams/operation.ts @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/seams/operation.ts +import {Request} from '../http/request.js'; +import type {Headers} from '../http/headers.js'; +import type {QueryParams} from '../http/query-params.js'; +import type {Method} from '../http/method.js'; +import {UrlConstructionError, DexpaceError} from '../http/errors.js'; +import {encodeRfc3986Component} from '../http/rfc3986.js'; + +/** + * Thrown when `buildRequest()` cannot assemble a request from its descriptor: a `{name}` + * placeholder in `pathTemplate` has no value in `pathParams`, or a supplied value is a dot segment + * (`.`/`..`) that the WHATWG URL parser would normalize into a path rewrite instead of keeping as + * one literal segment. + * + * @public + */ +export class OperationAssemblyError extends DexpaceError { + /** The path parameter the failure is about — a structured field so log aggregators need not parse the message. */ + readonly parameterName: string; + + /** + * @param message - the human-readable failure description. + * @param parameterName - the name of the offending path parameter. + */ + constructor(message: string, parameterName: string) { + super(message); + this.parameterName = parameterName; + } +} + +/** + * The operation-input projection SEAM-26 requires: a method and path template are always required, + * the four remaining projections default to empty when omitted. `?: T | undefined` (not a bare + * `?: T`) is required under `exactOptionalPropertyTypes` so a generator that spreads a partial + * object or assigns every field including the empty ones can pass `undefined` explicitly without a + * type error. + * + * @public + */ +export interface OperationDescriptor { + /** The HTTP method the operation is issued with. Always required (SEAM-26). */ + readonly method: Method; + + /** + * The path to project onto the base URL, with `{name}` placeholders for path parameters. Always + * required (SEAM-26); an empty string leaves the base URL's path untouched (SEAM-27). + */ + readonly pathTemplate: string; + + /** + * Values for `pathTemplate`'s `{name}` placeholders. Every placeholder must have a value here; + * each value is percent-encoded as a single path segment, so a value containing `/` cannot inject + * an extra segment (SEAM-27). Defaults to empty. + */ + readonly pathParams?: Readonly> | undefined; + + /** + * The operation's query parameters, appended after any query the base URL already carries + * (SEAM-27). Defaults to empty. + */ + readonly query?: QueryParams | undefined; + + /** The operation's headers, carried onto the assembled request as-is. Defaults to empty. */ + readonly headers?: Headers | undefined; + + /** + * The operation's body. Carried, not encoded — serialization is a separate seam's concern + * (SEAM-26). Defaults to absent. + */ + readonly body?: unknown; +} + +const PATH_PARAM_RE = /\{([^{}]+)\}/g; + +function parseBaseUrl(baseUrl: string | URL): URL { + if (baseUrl instanceof URL) return new URL(baseUrl.href); + try { + return new URL(baseUrl); + } catch (e: unknown) { + throw new UrlConstructionError( + `malformed or non-absolute base URL: ${baseUrl}`, + { + cause: e, + }, + ); + } +} + +function normalizeBaseUrl(baseUrl: string | URL): URL { + const parsed = parseBaseUrl(baseUrl); + if (parsed.hash !== '') { + throw new UrlConstructionError( + `base URL must not include a fragment: ${parsed.href}`, + ); + } + return parsed; +} + +function substitutePathParams( + template: string, + pathParams: Readonly> | undefined, +): string { + return template.replace(PATH_PARAM_RE, (_match, name: string) => { + const value = pathParams?.[name]; + if (value === undefined) { + throw new OperationAssemblyError( + `missing value for path parameter "${name}"`, + name, + ); + } + // "." and ".." are RFC 3986 unreserved, so they survive encoding — and the WHATWG URL parser + // treats "%2E" the same as "." during dot-segment normalization, so percent-encoding cannot + // keep them literal either. Rejection is the only lossless option: silently forwarding ".." + // would let a path value rewrite the path. + if (value === '.' || value === '..') { + throw new OperationAssemblyError( + `path parameter "${name}" must not be a dot segment ("." or "..")`, + name, + ); + } + return encodeRfc3986Component(value); + }); +} + +function composePath(basePath: string, substitutedTemplate: string): string { + if (substitutedTemplate === '') return basePath; + const normalizedTemplate = substitutedTemplate.startsWith('/') + ? substitutedTemplate + : `/${substitutedTemplate}`; + const trimmedBase = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath; + return `${trimmedBase}${normalizedTemplate}`; +} + +function composeQuery( + baseSearch: string, + operationQuery: QueryParams | undefined, +): string { + const rawBaseQuery = baseSearch.startsWith('?') + ? baseSearch.slice(1) + : baseSearch; + // SEAM-27: the base query's dangling separator is dropped before the operation query is appended. + const baseQueryPart = rawBaseQuery.replace(/&+$/, ''); + const operationQueryPart = operationQuery?.encode() ?? ''; + return [baseQueryPart, operationQueryPart] + .filter(part => part !== '') + .join('&'); +} + +/** + * Projects an {@link OperationDescriptor} onto a base URL, producing a well-formed {@link Request}. + * Path placeholders are substituted through `encodeRfc3986Component`, so a placeholder value + * containing `/` is encoded (`%2F`), never split into an extra path segment (SEAM-27). Dot-segment + * values (`.`, `..`) are rejected outright — the WHATWG URL parser treats `%2E` the same as `.`, so + * no encoding can keep them literal. + * + * @param baseUrl - the absolute base URL to project the operation onto. + * @param operation - the operation to assemble into a request. + * @returns the assembled request. + * @throws {@link OperationAssemblyError} when a `{name}` placeholder has no value in `pathParams`, + * or a supplied value is a dot segment (`.`/`..`) — fix the descriptor; no request was assembled. + * @throws {@link UrlConstructionError} when `baseUrl` is malformed, non-absolute, or carries a + * fragment — supply a clean absolute base URL. + * + * @public + */ +export function buildRequest( + baseUrl: string | URL, + operation: OperationDescriptor, +): Request { + const base = normalizeBaseUrl(baseUrl); + const substitutedPath = substitutePathParams( + operation.pathTemplate, + operation.pathParams, + ); + + const target = new URL(base.href); + target.pathname = composePath(base.pathname, substitutedPath); + target.search = composeQuery(base.search, operation.query); + + const requestBuilder = Request.newBuilder() + .method(operation.method) + .url(target); + if (operation.headers !== undefined) + requestBuilder.headers(operation.headers); + if (operation.body !== undefined) requestBuilder.body(operation.body); + return requestBuilder.build(); +} diff --git a/packages/core/src/seams/serde.test.ts b/packages/core/src/seams/serde.test.ts new file mode 100644 index 0000000..ccd911b --- /dev/null +++ b/packages/core/src/seams/serde.test.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/seams/serde.test.ts +// Exercises: SEAM-19 (mediaType required, never defaulted) — a compile-time check only (styleguide 11.6); +// `bun test` executes this file but does not typecheck it (its transpiler strips types without checking them). +// The assertions only actually fire under `bun run typecheck` — see the plan's Task 5 Step 3. +import {test} from 'bun:test'; +import {expectTypeOf} from 'expect-type'; +import type {Serde} from './serde.js'; + +test('mediaType is a required, non-optional string', () => { + expectTypeOf>() + .toHaveProperty('mediaType') + .toEqualTypeOf(); +}); + +test("deserialize's return type is bound to the instance's T", () => { + expectTypeOf['deserialize']>().returns.toEqualTypeOf(); +}); + +test("serialize's parameter type is bound to the instance's T", () => { + expectTypeOf['serialize']>() + .parameter(0) + .toEqualTypeOf(); +}); + +test('an implementation without mediaType is rejected (negative case, styleguide 11.6)', () => { + // @ts-expect-error -- SEAM-19: mediaType is required and never defaulted; omitting it must not compile + const missingMediaType: Serde = { + serialize: (value: string): unknown => value, + deserialize: (data: unknown): string => String(data), + }; + void missingMediaType; +}); diff --git a/packages/core/src/seams/serde.ts b/packages/core/src/seams/serde.ts new file mode 100644 index 0000000..ecc5c79 --- /dev/null +++ b/packages/core/src/seams/serde.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/seams/serde.ts + +/** + * @internal + * Provisional. `deserialize(data: unknown): T` with `T` inferred from the instance is exactly the + * erased/inferred generic SEAM-21 forbids ("deserialization MUST require an explicit runtime type + * token"). Phase 6's type-witness mechanism will change this interface's shape — do not export this + * from `packages/core/src/index.ts`. + */ +export interface Serde { + readonly mediaType: string; + serialize(value: T): unknown; + deserialize(data: unknown): T; +} diff --git a/packages/core/src/seams/transport.test.ts b/packages/core/src/seams/transport.test.ts new file mode 100644 index 0000000..81a78ea --- /dev/null +++ b/packages/core/src/seams/transport.test.ts @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/seams/transport.test.ts +// Exercises: SEAM-18's residual (composeSignal is the per-call-options-threading helper's cancellation half), +// XCUT-2 (timeout vs. caller-cancellation told apart by signal.reason.name, not a message string). +// No stub Transport is constructed — neither composeSignal nor isTimeoutSignal takes or returns one. +import {describe, expect, test} from 'bun:test'; +import { + composeSignal, + isTimeoutSignal, + CancellationError, +} from './transport.js'; + +describe('composeSignal', () => { + test('returns undefined when neither input is supplied', () => { + expect(composeSignal()).toBeUndefined(); + }); + + test('returns the user signal itself when only a user signal is supplied', () => { + const controller = new AbortController(); + expect(composeSignal(controller.signal)).toBe(controller.signal); + }); + + test('returns a timeout signal when only a timeout is supplied', () => { + const signal = composeSignal(undefined, 20); + expect(signal).toBeInstanceOf(AbortSignal); + }); + + test('composes both into a distinct signal that aborts when either input fires', () => { + const controller = new AbortController(); + const combined = composeSignal(controller.signal, 20); + expect(combined).toBeInstanceOf(AbortSignal); + expect(combined).not.toBe(controller.signal); + + controller.abort(new CancellationError('cancelled by caller')); + expect(combined?.aborted).toBe(true); + }); +}); + +describe('isTimeoutSignal', () => { + test('reports true for a fired AbortSignal.timeout()', async () => { + const signal = AbortSignal.timeout(5); + await new Promise(resolve => { + signal.addEventListener('abort', resolve, {once: true}); + }); + expect(isTimeoutSignal(signal)).toBe(true); + }); + + test('reports false for a fired caller-initiated CancellationError abort', () => { + const controller = new AbortController(); + controller.abort(new CancellationError('cancelled by caller')); + expect(isTimeoutSignal(controller.signal)).toBe(false); + }); + + test('reports false for a signal that never aborted', () => { + expect(isTimeoutSignal(new AbortController().signal)).toBe(false); + }); +}); diff --git a/packages/core/src/seams/transport.ts b/packages/core/src/seams/transport.ts new file mode 100644 index 0000000..904edf5 --- /dev/null +++ b/packages/core/src/seams/transport.ts @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/seams/transport.ts +import type {Request} from '../http/request.js'; +import type {Response} from '../http/response.js'; +import type {RequestOptions} from '../http/request-options.js'; +import {DexpaceError} from '../http/errors.js'; + +/** + * The single async HTTP transport seam — SEAM-11 (sync) and SEAM-16 (async) collapse into one + * `Promise` contract; SEAM-17's canonical async pivot is native `Promise`, so there is no + * second async ecosystem to bridge. + * + * @public + */ +export interface Transport { + /** + * Sends a request and resolves with its response. + * + * MUST be safe for concurrent calls; all per-request state confined to locals or the returned + * promise graph — never instance fields on the transport (SEAM-12; conformance test is Phase 8's, + * once a real transport exists to fire concurrent requests through). + * + * MUST NOT pre-buffer the response body — the caller owns reading and closing it (SEAM-11; the + * streaming body type arrives in Phase 3, but the obligation binds every implementation from day + * one). + * + * Aborting `signal` while the call is in flight SHOULD be treated as a best-effort request to + * abort the underlying exchange and release its transport resources — sockets, descriptors + * (SEAM-13). + * + * A `signal` abort that fires *after* the returned promise has resolved MUST NOT close the + * already-delivered response body — the caller still owns closing it, even when discarding the + * value (SEAM-16). Do not wire an unconditional `abort` listener that cancels the body. + * + * After the underlying fetch resolves, check whether `signal` already fired before delivering the + * response; if so, cancel the response body instead of resolving. That cleanup path MUST be + * awaited or given `.catch(() => {})` — an unhandled rejection there crashes the process under + * Node's default `unhandledRejection` policy (SEAM-30; implemented by Phase 8's real adapters). + * + * Per-call `options` MUST be threaded through to the underlying client, never silently dropped. A + * transport that ignores `options` MUST behave identically to the no-options call (SEAM-18's one + * surviving, non-bridge-specific obligation). + * + * @param request - the request to send. + * @param options - per-call operational overrides; MUST be threaded through, never dropped. + * @param signal - an optional abort signal; see the cancellation obligations above. + * @returns a promise that resolves to a non-null response, or rejects. + */ + send( + request: Request, + options?: RequestOptions, + signal?: AbortSignal, + ): Promise; + + /** + * Releases resources this transport itself created. + * + * MUST be idempotent, release only resources the transport itself created, and never touch a + * caller-supplied client/executor (SEAM-14). A lightweight transport with nothing to release MAY + * implement this as a no-op: `async close(): Promise {}`. The signature is locked from this + * phase on — adding a required method to a published seam later is a breaking change; only the + * *behavior* waits for Phase 8. + * + * Behavior of `send()` after `close()` has resolved is unspecified at the seam level (SEAM-15); + * each Phase 8 adapter picks a mode (throw vs. rejected promise) and documents it. + * + * @returns a promise that resolves once the transport's own resources are released. + */ + close(): Promise; +} + +/** + * Wraps `AbortSignal.timeout(ms)` and `AbortSignal.any([...])` into the one signal a + * `Transport.send()` call should honor. Returns `undefined` when neither input is supplied, so a + * transport can pass the result straight to `fetch` without a branch. Reusable by Phase 5's retry + * logic. + * + * @param userSignal - an optional caller-supplied abort signal. + * @param timeoutMs - an optional timeout, in milliseconds. + * @returns the composed signal, the sole supplied signal, or `undefined` when neither is supplied. + * + * @public + */ +export function composeSignal( + userSignal?: AbortSignal, + timeoutMs?: number, +): AbortSignal | undefined { + const timeoutSignal = + timeoutMs !== undefined ? AbortSignal.timeout(timeoutMs) : undefined; + + if (userSignal !== undefined && timeoutSignal !== undefined) { + return AbortSignal.any([userSignal, timeoutSignal]); + } + return userSignal ?? timeoutSignal; +} + +/** + * True when `signal` was aborted by `AbortSignal.timeout()`. Checks the structured `reason.name` + * field rather than `reason instanceof DOMException` — `instanceof` is realm-bound, so a signal + * created inside a `node:vm` context or a worker would fail the check even though it is a genuine + * timeout (XCUT-2: told apart by a structured field on ambient state, not by matching a message + * string). + * + * @param signal - the signal to inspect. + * @returns `true` when `signal.reason` is a timeout reason. + * + * @public + */ +export function isTimeoutSignal(signal: AbortSignal): boolean { + const reason = signal.reason as {name?: unknown} | null | undefined; + return ( + typeof reason === 'object' && + reason !== null && + reason.name === 'TimeoutError' + ); +} + +/** + * Thrown for an explicit caller-initiated abort of an in-flight `Transport.send()` call. + * + * @public + */ +export class CancellationError extends DexpaceError {} diff --git a/scripts/verify-node-floor.mjs b/scripts/verify-node-floor.mjs new file mode 100644 index 0000000..1344a07 --- /dev/null +++ b/scripts/verify-node-floor.mjs @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +// scripts/verify-node-floor.mjs +// +// NFR-10/NFR-17 residual pulled forward from Phase 3: CI must run the *built artifact* against the +// declared minimum Node version, not just the runner default. This forces the two-signal branch of +// composeSignal() — the one that calls AbortSignal.any(), the API that landed in exactly Node +// 18.17.0, the repo's declared floor (engines.node ">=18.17"). +import assert from 'node:assert/strict'; +import {composeSignal} from '@dexpace/core'; + +const controller = new AbortController(); +const combined = composeSignal(controller.signal, 50); + +assert.ok( + combined instanceof AbortSignal, + 'composeSignal() must return an AbortSignal when both a user signal and a timeout are supplied', +); +assert.notEqual( + combined, + controller.signal, + 'the combined signal must be a distinct AbortSignal.any() result, not the raw user signal', +); + +console.log( + `node-floor check passed: AbortSignal.any() resolved correctly on Node ${process.version}`, +); diff --git a/tsconfig.base.json b/tsconfig.base.json index 0f0fd13..0c1d0df 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -5,7 +5,8 @@ "module": "nodenext", "moduleResolution": "nodenext", "lib": [ - "ES2022" + "ES2022", + "DOM" ], "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, From cb8fe400732aefa7403133e58b6ffad1cfd3986b Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Mon, 10 Aug 2026 14:44:32 +0300 Subject: [PATCH 2/2] fix(core): give CancellationError an explicit constructor to satisfy the coverage gate. --- packages/core/etc/core.api.md | 1 + packages/core/src/seams/transport.ts | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/core/etc/core.api.md b/packages/core/etc/core.api.md index ac14728..330de32 100644 --- a/packages/core/etc/core.api.md +++ b/packages/core/etc/core.api.md @@ -14,6 +14,7 @@ export function buildRequest(baseUrl: string | URL, operation: OperationDescript // @public export class CancellationError extends DexpaceError { + constructor(message: string, options?: ErrorOptions); } // @public diff --git a/packages/core/src/seams/transport.ts b/packages/core/src/seams/transport.ts index 904edf5..8cd62fc 100644 --- a/packages/core/src/seams/transport.ts +++ b/packages/core/src/seams/transport.ts @@ -120,4 +120,23 @@ export function isTimeoutSignal(signal: AbortSignal): boolean { * * @public */ -export class CancellationError extends DexpaceError {} +export class CancellationError extends DexpaceError { + /** + * Forwards to {@link DexpaceError} unchanged. + * + * This constructor is deliberately explicit rather than inherited. An empty-bodied derived class + * (`extends DexpaceError {}`) gets a synthesized constructor that Bun's coverage counts as a + * function but never marks hit, even though the tests below construct this class — no test can + * cover it. `bunfig.toml`'s `coverageThreshold = 0.8` is per-file and applies to function coverage + * as well as lines, so in a three-function module that single artifact is 66% and fails the gate. + * Do not "simplify" this away; `coverageThreshold`'s object form (`{line = 0.8}`) is silently + * ignored on Bun 1.3.x, so narrowing the threshold to lines only is not an alternative. + * + * @param message - the human-readable failure description. + * @param options - standard error options; pass `{cause}` when wrapping a caught error. + */ + // eslint-disable-next-line @typescript-eslint/no-useless-constructor -- load-bearing for coverage, not for behavior: the synthesized constructor of an empty-bodied subclass is uncoverable and fails bunfig's per-file 0.8 function threshold. Remove when Bun honors `coverageThreshold = {line = 0.8}`. + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +}