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.

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.

49 changes: 36 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ A Node.js/TypeScript HTTP SDK platform, built as a **port of a language-agnostic
spec in `docs/product-spec/` is normative and numbered; the code exists to satisfy it. Work here is
spec-driven, not feature-driven: before implementing anything, find the requirement IDs it must satisfy.

Bun workspace. One published package today — `@dexpace/core` (`packages/core`) — with more planned per
`docs/sdk-design-nodejs/02-package-and-workspace-layout.md`.
Bun workspace. Two published packages today — `@dexpace/core` (`packages/core`) and the reference wire codec
`@dexpace/codec-json` (`packages/codec-json`, a `@dexpace/core` **peer**, never a dependency) — with more
planned per `docs/sdk-design-nodejs/02-package-and-workspace-layout.md`. Every gate below runs over all of
them, not over core alone.

## Commands

Expand All @@ -18,15 +20,29 @@ All run from the repo root unless noted.
```bash
bun install --frozen-lockfile

bun run typecheck # tsc --noEmit against packages/core/tsconfig.json
bun run lint # gts lint . — formatting AND type-aware rules; fatal
bun run fix # gts fix . — autofixes formatting/lint
bun run build # tsc -p packages/core/tsconfig.build.json → dist/
bun test # coverage is on by default (bunfig.toml), 80% line floor
bun run build:core # tsc -b of core's declarations; incremental, and a prerequisite of the three below
bun run typecheck # build:core, then tsc --noEmit per package (core, then codec-json)
bun run lint # build:core, then gts lint . — formatting AND type-aware rules; fatal
bun run fix # build:core, then gts fix . — autofixes formatting/lint
bun run build # build:core, then plain tsc for codec-json → each package's dist/
bun test # needs `build` first (see below); coverage on by default, 80% line floor
bun run test:node # Node-runtime conformance against the BUILT artifact; needs `build` first
```

**Anything that resolves `@dexpace/core` by package name needs core's `dist/` to exist**, from Phase 6a on —
`@dexpace/codec-json` reaches core only through its published entry point, and both `tsc` and Bun follow the
`types`/`main` fields there. `typecheck`, `lint`, `fix`, and `build` each run `build:core` first for that
reason, so every one of them works on a fresh clone. `build:core` is `tsc -b`, so a warm repeat is close to
free. Do not drop that prefix to "save a step": without it `typecheck` fails with 30 unresolved-module errors
the moment `dist/` is absent, which is exactly what a CI runner sees.

`bun test` runs the unit suite on **Bun** and is scoped to `packages/` (`bunfig.toml`'s `[test] root`).
**It needs `bun run build` to have run first**, from Phase 6a on: `@dexpace/codec-json`'s tests reach core
through its published entry point, which Bun resolves to `packages/core/dist/`. On a fresh clone they cannot
resolve core at all; against a stale `dist/` they report green over yesterday's core. CI is safe — its Build
step precedes its Test step. The root `test` script deliberately does not build first, so the inner loop stays
fast; rebuild when you have changed `packages/core/src/`.

`test:node` is a separate, thin layer under `test/node-conformance/` that runs the same built package under
`node --test`, because Bun's Web Streams / `AbortSignal` / `Uint8Array` behavior is an independent
implementation of Node's and `src/io/` is where they diverge. **A phase that touches a runtime-divergent
Expand All @@ -39,28 +55,35 @@ bun test packages/core/src/http/media-type.test.ts
bun test -t 'rejects blank input' # filter by test name
```

API surface (report is committed at `packages/core/etc/core.api.md`):
API surface — one committed report per package (`packages/core/etc/core.api.md`,
`packages/codec-json/etc/codec-json.api.md`):

```bash
cd packages/core && bun run api:local # regenerate the report after changing exports
cd packages/core && bun run api:ci # verify it matches — this is what CI runs
cd packages/core && bun run api:local # regenerate that package's report after changing its exports
cd packages/codec-json && bun run api:local
bun run api # verify BOTH match — this is what CI runs
```

Release-shape and invariant gates:

```bash
bun run lint:publish # publint + attw against the built package
bun run verify:dual-consumption # plain `node` imports the built package and runs it
bun run lint:publish # publint + attw against every built package
bun run verify:dual-consumption # plain `node` imports each built package and exercises it end to end
bun run verify:consumer-types # the built .d.ts compiles on the declared `lib` with types: []
bun run test:node # CI runs this as a matrix over engines.node's floor and current LTS
bun run verify:seam-1 # asserts @dexpace/core has zero runtime dependencies
bun run verify:seam-1 # zero runtime dependencies in EVERY package, plus the @dexpace/core
# peer-dependency rule that guards the dual-package hazard
bun run verify:runtime-floor # tsconfig target vs package engines.node consistency
bun run audit # bun audit --audit-level=high --prod
```

**Every one of these is a blocking CI step** (`.github/workflows/ci.yml`). Run the full set before claiming
work is done — `bun test` passing is not sufficient evidence.

`bun run test:scripts` (`node --test scripts/*.test.mjs`) tests the *gates themselves* — the knowledge CLI and
`verify-seam-1.mjs`. It is **not** wired into CI yet (`docs/open-items.md` H13), so run it by hand after
touching anything in `scripts/`.

## Documentation hierarchy

Four distinct trees, easy to confuse:
Expand Down
36 changes: 31 additions & 5 deletions bun.lock

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

10 changes: 8 additions & 2 deletions bunfig.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@
# test/node-conformance/*.test.mjs -- the Node-only layer that exists precisely because it must NOT
# run on Bun (checkpoint 5.9). Running it under both runners would inflate the unit count and quietly
# erase the distinction the suite was added to draw. It likewise keeps `scripts/*.test.mjs` -- repo
# tooling, run via `bun run test:knowledge` (`node --test`) -- out of both the run and the coverage
# floor, which is a statement about `packages/core`.
# tooling, run via `bun run test:scripts` (`node --test`) -- out of both the run and the coverage
# floor, which is a statement about `packages/*/src`.
root = "packages"
coverage = true
coverageThreshold = 0.8
coverageSkipTestFiles = true
# Exclude the BUILT artifact. `@dexpace/codec-json` is a separate package and reaches core only
# through its public entry point, which Bun resolves to `packages/core/dist/index.js` -- so from
# Phase 6a on, running the suite instruments core twice: once as `src/` (the statement this floor is
# about) and once as `dist/`, where only the handful of exports the codec touches are ever reached.
# Left in, the duplicate halves the reported number without a line of real coverage changing.
coveragePathIgnorePatterns = ["**/dist/**"]
Loading
Loading