Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,113 @@
# @suaveplan/error - Foundation error substrate — SuaveplanError base class, stable code registry, cause-chain traversal, Zod-validated wire format (JSON + MessagePack), redaction, OTel span-event adapter, i18n resolver hook. Zero @suaveplan/* deps.
## Summary
Foundation error substrate for Suaveplan. Every non-testing `@suaveplan/*` package extends this library's `SuaveplanError` base class and uses its stable code registry. The package provides: `SuaveplanError<TCode>` — an abstract base with literal-typed discriminant codes, injectable clock, context bag, traceId, and cause chaining; `defineCodes(namespace, reasons)` — a frozen, literal-typed registry that makes error codes refactor-safe; `walkCauses(err)` — a cycle-safe, depth-bounded generator for traversing nested error causes; lossless JSON and binary wire formats (`toJSON`/`fromJSON`/`toBinary`/`fromBinary`) with Zod validation; `markSensitive`/`unwrapSensitive` for redaction at serialization boundaries; `toSpanEvent` for OpenTelemetry `exception` span events without an OTel SDK import; and a `setMessageResolver` hook for i18n. Zero `@suaveplan/*` runtime or peer dependencies — this is the lowest-level package in the stack.
<!-- begin:auto-docs -->
## Documentation tree
- [package-error](./src/base/package-error.md) — `ErrorPackageError` is the one concrete `SuaveplanError` subclass shipped by `@suaveplan/error` itself. It carries the…
- [suaveplan-error](./src/base/suaveplan-error.md) — `SuaveplanError` is the abstract base class that every non-testing package in the project extends. It standardises the…
- [causes](./src/causes/walk-causes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [walk-causes](./src/causes/walk-causes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [define-codes](./src/codes/define-codes.md) — `defineCodes` is the canonical way to declare a stable, literal-typed error code registry for a package. It takes a nam…
- [error-codes](./src/codes/error-codes.md) — `ERROR_CODES` is the package-local registry for `@suaveplan/error` itself. It enumerates every failure mode the foundat…
- [i18n](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [resolver](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [to-span-event](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [redact](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sensitive](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [from-binary](./src/serialize/from-binary.md) — `fromBinary` is the decode counterpart to `toBinary`. It accepts a `Uint8Array` produced by `toBinary` (or any compatib…
- [from-json](./src/serialize/from-json.md) — `fromJSON` is the decode counterpart to `toJSON`. It Zod-validates the incoming payload against `wireErrorSchema` and r…
- [to-binary](./src/serialize/to-binary.md) — `toBinary` encodes any error-shaped value into a compact byte payload suitable for binary transports (message queues, W…
- [to-json](./src/serialize/to-json.md) — `toJSON` serialises any error-shaped value to a wire-safe plain object that round-trips losslessly back through `fromJS…
<!-- end:auto-docs --> - [walk-causes](./src/causes/walk-causes.md) — <!-- begin:auto-docs --> _(no submodules)_- [define-codes](./src/codes/define-codes.md) — `defineCodes` is the canonical way to declare a stable, literal-typed error code registry for a package. It takes a nam…
- [error-codes](./src/codes/error-codes.md) — `ERROR_CODES` is the package-local registry for `@suaveplan/error` itself. It enumerates every failure mode the foundat…
- [i18n](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_- [to-span-event](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_- [redact](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_- [sensitive](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_- [from-binary](./src/serialize/from-binary.md) — `fromBinary` is the decode counterpart to `toBinary`. It accepts a `Uint8Array` produced by `toBinary` (or any compatib…
- [from-json](./src/serialize/from-json.md) — `fromJSON` is the decode counterpart to `toJSON`. It Zod-validates the incoming payload against `wireErrorSchema` and r…
- [to-binary](./src/serialize/to-binary.md) — `toBinary` encodes any error-shaped value into a compact byte payload suitable for binary transports (message queues, W…
- [to-json](./src/serialize/to-json.md) — `toJSON` serialises any error-shaped value to a wire-safe plain object that round-trips losslessly back through `fromJS…
- [walk-causes](./src/causes/walk-causes.md) — <!-- begin:auto-docs --> _(no submodules)_- [define-codes](./src/codes/define-codes.md) — `defineCodes` is the canonical way to declare a stable, literal-typed error code registry for a package. It takes a nam…
- [error-codes](./src/codes/error-codes.md) — `ERROR_CODES` is the package-local registry for `@suaveplan/error` itself. It enumerates every failure mode the foundat…
- [i18n](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_- [to-span-event](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_- [redact](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_- [sensitive](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_- [from-binary](./src/serialize/from-binary.md) — `fromBinary` is the decode counterpart to `toBinary`. It accepts a `Uint8Array` produced by `toBinary` (or any compatib…
- [from-json](./src/serialize/from-json.md) — `fromJSON` is the decode counterpart to `toJSON`. It Zod-validates the incoming payload against `wireErrorSchema` and r…
- [to-binary](./src/serialize/to-binary.md) — `toBinary` encodes any error-shaped value into a compact byte payload suitable for binary transports (message queues, W…
- [to-json](./src/serialize/to-json.md) — `toJSON` serialises any error-shaped value to a wire-safe plain object that round-trips losslessly back through `fromJS…
- [walk-causes](./src/causes/walk-causes.md) — <!-- begin:auto-docs --> _(no submodules)_- [define-codes](./src/codes/define-codes.md) — `defineCodes` is the canonical way to declare a stable, literal-typed error code registry for a package. It takes a nam…
- [error-codes](./src/codes/error-codes.md) — `ERROR_CODES` is the package-local registry for `@suaveplan/error` itself. It enumerates every failure mode the foundat…
- [i18n](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_- [to-span-event](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_- [redact](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_- [sensitive](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_- [from-binary](./src/serialize/from-binary.md) — `fromBinary` is the decode counterpart to `toBinary`. It accepts a `Uint8Array` produced by `toBinary` (or any compatib…
- [from-json](./src/serialize/from-json.md) — `fromJSON` is the decode counterpart to `toJSON`. It Zod-validates the incoming payload against `wireErrorSchema` and r…
- [to-binary](./src/serialize/to-binary.md) — `toBinary` encodes any error-shaped value into a compact byte payload suitable for binary transports (message queues, W…
- [to-json](./src/serialize/to-json.md) — `toJSON` serialises any error-shaped value to a wire-safe plain object that round-trips losslessly back through `fromJS…
- [walk-causes](./src/causes/walk-causes.md) — <!-- begin:auto-docs --> _(no submodules)_- [define-codes](./src/codes/define-codes.md) — `defineCodes` is the canonical way to declare a stable, literal-typed error code registry for a package. It takes a nam…
- [error-codes](./src/codes/error-codes.md) — `ERROR_CODES` is the package-local registry for `@suaveplan/error` itself. It enumerates every failure mode the foundat…
- [i18n](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ - [resolver](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_ - [to-span-event](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_- [redact](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sensitive](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_- [from-binary](./src/serialize/from-binary.md) — `fromBinary` is the decode counterpart to `toBinary`. It accepts a `Uint8Array` produced by `toBinary` (or any compatib…
- [from-json](./src/serialize/from-json.md) — `fromJSON` is the decode counterpart to `toJSON`. It Zod-validates the incoming payload against `wireErrorSchema` and r…
- [to-binary](./src/serialize/to-binary.md) — `toBinary` encodes any error-shaped value into a compact byte payload suitable for binary transports (message queues, W…
- [to-json](./src/serialize/to-json.md) — `toJSON` serialises any error-shaped value to a wire-safe plain object that round-trips losslessly back through `fromJS…
## Install
```bash
bun add @suaveplan/error
```
## Highlights
- **`SuaveplanError<TCode>`** — abstract base with literal-typed code discriminants, injectable clock, context, traceId, cause
- **`defineCodes(namespace, reasons)`** — frozen, literal-typed code registry
- **`walkCauses(err)`** — cycle-safe, depth-bounded cause-chain generator
- **`toJSON`/`fromJSON`/`toBinary`/`fromBinary`** — lossless, Zod-validated wire format; binary form is UTF-8-encoded canonical JSON, no external encoder
- **`markSensitive` / `unwrapSensitive`** — redaction tagging applied at the serialization boundary
- **`toSpanEvent`** — OpenTelemetry `exception` span event payload, no OTel SDK import
- **`setMessageResolver`** — i18n hook; default resolver returns the key verbatim
## Foundation rules honoured
- Zero `@suaveplan/*` runtime or peer deps
- Runtime deps: `tslib`, `zod`
- Zero Node / browser built-in references in `src/` (guarded by `tests/no-disallowed.test.ts`)
- `sideEffects: false`, per-module subpath exports
- 100% statement / branch / function / line coverage (guarded in `vitest.config.ts`)
## Usage
```ts
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const AUTH_CODES = defineCodes("AUTH", [
"LOGIN.INVALID",
"LOGIN.LOCKED",
] as const);
export class AuthError extends SuaveplanError<
(typeof AUTH_CODES)[keyof typeof AUTH_CODES]
> {
public constructor(
code: (typeof AUTH_CODES)[keyof typeof AUTH_CODES],
key: string,
options?: ConstructorParameters<typeof SuaveplanError>[2]
) {
super(code, key, options);
}
}
throw new AuthError(AUTH_CODES["LOGIN.INVALID"], "error.auth.login.invalid", {
context: { user: "alice" },
traceId: "trace-1",
});
```
## Wire format
`toJSON(err)` returns a plain object with `__suaveplanError: 1`, ready for JSON-safe transport. `fromJSON(value)` validates and reconstructs the payload. `toBinary` / `fromBinary` exchange the same content as a UTF-8 byte array.
## Testing
```bash
bunx turbo lint typecheck test --filter=@suaveplan/error
bunx turbo test:coverage --filter=@suaveplan/error
```

View File

@@ -0,0 +1,95 @@
{
"name": "@suaveplan/error",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Foundation error substrate — SuaveplanError base class, stable code registry, cause-chain traversal, Zod-validated wire format (JSON + MessagePack), redaction, OTel span-event adapter, i18n resolver hook. Zero @suaveplan/* deps.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./base": {
"types": "./dist/base/suaveplan-error.d.ts",
"default": "./dist/base/suaveplan-error.js"
},
"./codes": {
"types": "./dist/codes/define-codes.d.ts",
"default": "./dist/codes/define-codes.js"
},
"./causes": {
"types": "./dist/causes/walk-causes.d.ts",
"default": "./dist/causes/walk-causes.js"
},
"./serialize": {
"types": "./dist/serialize/index.d.ts",
"default": "./dist/serialize/index.js"
},
"./redact": {
"types": "./dist/redact/sensitive.d.ts",
"default": "./dist/redact/sensitive.js"
},
"./otel": {
"types": "./dist/otel/to-span-event.d.ts",
"default": "./dist/otel/to-span-event.js"
},
"./i18n": {
"types": "./dist/i18n/resolver.d.ts",
"default": "./dist/i18n/resolver.js"
},
"./wire.schemas": {
"types": "./dist/serialize/wire.schemas.d.ts",
"default": "./dist/serialize/wire.schemas.js"
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"bench": "bun run src/__benchmarks__/serialize.bench.ts",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,6 @@
export { ErrorPackageError } from "./package-error";
export { defaultClock, SuaveplanError } from "./suaveplan-error";
export type {
ErrorContext,
SuaveplanErrorOptions,
} from "./suaveplan-error.types";

View File

@@ -0,0 +1,57 @@
# package-error
## Purpose
`ErrorPackageError` is the one concrete `SuaveplanError` subclass shipped by `@suaveplan/error` itself. It carries the package-local error codes used by the serializer, binary codec, and cause-chain walker so that failure modes inside this foundation package still follow the same structured shape every other package enforces. It exists only because the base class is abstract; callers outside this package should extend `SuaveplanError` in their own subclass rather than using this one.
## Features
- `code` narrowed to `ErrorCode` (`ERROR.WIRE.MALFORMED`, `ERROR.BINARY.*`, `ERROR.CAUSE.*`)
- Public constructor that forwards to the `protected` base constructor
- Preserves `cause`, `context`, `timestamp`, `traceId` through the base class
- Used internally by `fromJSON`, `fromBinary`, and depth-bounded traversal
## Usage: Basic Example
```ts
import { ErrorPackageError, ERROR_CODES } from "@suaveplan/error";
throw new ErrorPackageError(
ERROR_CODES["WIRE.MALFORMED"],
"error.error.wire.malformed"
);
```
## Usage: Advanced Example
```ts
import { ErrorPackageError, ERROR_CODES, toJSON } from "@suaveplan/error";
function parse(payload: unknown): unknown {
try {
return JSON.parse(payload as string);
} catch (cause) {
throw new ErrorPackageError(
ERROR_CODES["WIRE.MALFORMED"],
"error.error.wire.malformed",
{ cause, context: { payload } }
);
}
}
try {
parse("{not json");
} catch (err) {
console.error(toJSON(err));
}
```
## API Reference
### `class ErrorPackageError extends SuaveplanError<ErrorCode>`
- `new ErrorPackageError(code, messageKey, options?)` — `code` is one of the literal strings in `ErrorCode`; `options` matches `SuaveplanErrorOptions`
## Implementation Notes
The class exists only to widen the base's `protected` constructor to `public` — a Biome suppression is applied because the constructor body is a pass-through, which Biome flags as useless but is intentional for access-modifier widening. Consumers outside this package should not import `ErrorPackageError` to throw their own failures; they should extend `SuaveplanError` with codes created via `defineCodes` so their own error hierarchy is distinct in `instanceof` checks. The code union `ErrorCode` is derived from the runtime `ERROR_CODES` record, so adding a new reason to the registry automatically widens the class's type without any manual declaration maintenance.

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ERROR_CODES } from "../codes/error-codes.js";
import { ErrorPackageError } from "./package-error.js";
import { SuaveplanError } from "./suaveplan-error.js";
describe("ErrorPackageError", () => {
it("extends SuaveplanError and accepts a package code", {
timeout: 5000,
}, () => {
const err = new ErrorPackageError(
ERROR_CODES["WIRE.MALFORMED"],
"error.error.wire.malformed"
);
expect(err).toBeInstanceOf(SuaveplanError);
expect(err.code).toBe("ERROR.WIRE.MALFORMED");
});
});

View File

@@ -0,0 +1,18 @@
import type { ErrorCode } from "../codes/error-codes.js";
import { SuaveplanError } from "./suaveplan-error.js";
import type { SuaveplanErrorOptions } from "./suaveplan-error.types.js";
/**
* Concrete subclass shipped by `@suaveplan/error` for its own internal
* failure modes (wire validation, binary bounds, cycle detection).
*/
export class ErrorPackageError extends SuaveplanError<ErrorCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens the protected base constructor to public
public constructor(
code: ErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
}
}

View File

@@ -0,0 +1,83 @@
# suaveplan-error
## Purpose
`SuaveplanError` is the abstract base class that every non-testing package in the project extends. It standardises the shape of a thrown failure across processes, packages, and wire formats: a stable namespaced `code`, an i18n-keyed `message`, an optional structured `context`, a traceable `traceId`, an injectable `timestamp`, and a preserved `cause` chain. By centralising that shape, every downstream library gets structured errors for free and every external tool (logger, tracer, transport) can read them without package-specific adapters.
## Features
- Abstract class — instantiable only through a subclass
- Generic `TCode` parameter narrows `err.code` to the package's literal union
- Injectable clock for deterministic timestamps under test
- Optional `context` (redaction-aware) and `traceId`
- Preserves `cause` via the standard ES2022 `Error.cause`
- Calls `Error.captureStackTrace` where available, with a safe fallback for hosts that don't expose it
- Resolves `message` via the installed `setMessageResolver`; default returns the i18n key verbatim
## Usage: Basic Example
```ts
import { SuaveplanError, defineCodes } from "@suaveplan/error";
const AUTH_CODES = defineCodes("AUTH", ["LOGIN.INVALID"] as const);
class AuthError extends SuaveplanError<typeof AUTH_CODES["LOGIN.INVALID"]> {
public constructor(messageKey: string) {
super(AUTH_CODES["LOGIN.INVALID"], messageKey);
}
}
throw new AuthError("error.auth.login.invalid");
```
## Usage: Advanced Example
```ts
import { SuaveplanError, defineCodes } from "@suaveplan/error";
const ORDER_CODES = defineCodes("ORDER", ["PAYMENT.DECLINED"] as const);
class OrderError extends SuaveplanError<
typeof ORDER_CODES[keyof typeof ORDER_CODES]
> {
public constructor(
code: typeof ORDER_CODES[keyof typeof ORDER_CODES],
key: string,
options?: ConstructorParameters<typeof SuaveplanError>[2]
) {
super(code, key, options);
}
}
try {
throw new OrderError(ORDER_CODES["PAYMENT.DECLINED"], "error.order.payment.declined", {
cause: new Error("Stripe 402"),
context: { orderId: "o-1", user: "u-42" },
traceId: "trace-9",
clock: () => 1_700_000_000_000,
});
} catch (err) {
if (err instanceof OrderError && err.code === "ORDER.PAYMENT.DECLINED") {
console.error(err.traceId, err.timestamp, err.context, err.cause);
}
}
```
## API Reference
### `class SuaveplanError<TCode extends string = string>` *(abstract)*
- `readonly code: TCode` — the stable namespaced code (`PACKAGE.DOMAIN.REASON`)
- `readonly context?: Readonly<Record<string, unknown>>` — structured fields; redaction-aware at the wire boundary
- `readonly timestamp: number` — ms since epoch; filled from `options.clock()` or `defaultClock()`
- `readonly traceId?: string` — optional trace correlation id
- `cause?: unknown` — the ES2022 `Error.cause`; preserved across `walkCauses`
- `constructor(code, messageKey, options?)` *(protected)* — subclasses MUST widen to `public`
### `defaultClock(): number`
Thin wrapper over `Date.now()`. Lives in one file so tests can swap it.
## Implementation Notes
The constructor is `protected` so `SuaveplanError` cannot be instantiated directly; every subclass must widen to `public`, which is an intentional review point that forces each consumer to name its error hierarchy. `captureStackTrace` is guarded because not every host (older Safari, some WASM runtimes) exposes it; on those hosts the `stack` is whatever the `Error` superclass produces. Timestamps read through `options.clock ?? defaultClock` — `defaultClock` is the ONLY module-scope `Date.now()` call in the package, which keeps the package deterministic under test. Redaction happens at the serialization boundary (`toJSON`, `toBinary`), not at construction, so in-memory debugging always sees the real values. No Node / browser built-ins are referenced.

View File

@@ -0,0 +1,44 @@
/**
* Spec-conformance tests for `SuaveplanError`. Covers the two scenarios
* in `openspec/specs/error/spec.md` that the original test suite did not
* pin explicitly: subclass code-union narrowing and subclass-name in
* `err.stack`.
*/
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { SuaveplanError } from "./suaveplan-error.js";
class NarrowError extends SuaveplanError<"NARROW.X.Y" | "NARROW.X.Z"> {
constructor(code: "NARROW.X.Y" | "NARROW.X.Z", key = "k") {
super(code, key);
}
}
describe("SuaveplanError — code-union narrowing", () => {
it("subclass narrows err.code to its declared literal union", {
timeout: 5000,
}, () => {
const err: unknown = new NarrowError("NARROW.X.Y");
expect(err).toBeInstanceOf(NarrowError);
if (err instanceof NarrowError) {
// Compile-time assertion: code is the literal union, not `string`.
expectTypeOf(err.code).toEqualTypeOf<"NARROW.X.Y" | "NARROW.X.Z">();
// Runtime check, just to keep istanbul satisfied.
expect(err.code).toBe("NARROW.X.Y");
}
});
});
describe("SuaveplanError — stack trace preservation", () => {
it("err.stack contains the subclass name, not just 'Error'", {
timeout: 5000,
}, () => {
const err = new NarrowError("NARROW.X.Z");
const stack = err.stack ?? "";
expect(stack).toContain("NarrowError");
// The first non-empty line should reference the subclass, not
// the parent `Error`.
const firstLine = stack.split("\n").find((l) => l.trim().length > 0);
expect(firstLine).toBeDefined();
expect(firstLine).toMatch(/NarrowError/);
});
});

View File

@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { clearMessageResolver, setMessageResolver } from "../i18n/resolver.js";
import { SuaveplanError } from "./suaveplan-error.js";
class TestError extends SuaveplanError<"TEST.A" | "TEST.B"> {}
afterEach(() => {
clearMessageResolver();
});
describe("SuaveplanError — construction", () => {
it("sets code, name, default timestamp, message", {
timeout: 5000,
}, () => {
const err = new TestError("TEST.A", "m");
expect(err.code).toBe("TEST.A");
expect(err.name).toBe("TestError");
expect(err.message).toBe("m");
expect(typeof err.timestamp).toBe("number");
expect(err instanceof SuaveplanError).toBe(true);
});
it("uses injected clock for timestamp", { timeout: 5000 }, () => {
const err = new TestError("TEST.A", "m", { clock: () => 42 });
expect(err.timestamp).toBe(42);
});
it("carries context, cause, traceId", { timeout: 5000 }, () => {
const cause = new Error("root");
const err = new TestError("TEST.B", "m", {
context: { user: "x" },
cause,
traceId: "t-1",
});
expect(err.context).toEqual({ user: "x" });
expect(err.cause).toBe(cause);
expect(err.traceId).toBe("t-1");
});
it("omits context / traceId when not provided", { timeout: 5000 }, () => {
const err = new TestError("TEST.A", "m");
expect(err.context).toBeUndefined();
expect(err.traceId).toBeUndefined();
});
it("resolves message key through the installed resolver", {
timeout: 5000,
}, () => {
setMessageResolver((key, params) => `${key}!${params?.["x"] ?? ""}`);
const err = new TestError("TEST.A", "k", { context: { x: 1 } });
expect(err.message).toBe("k!1");
});
it("captureStackTrace fallback when host lacks Error.captureStackTrace", {
timeout: 5000,
}, () => {
const original = (Error as { captureStackTrace?: unknown })
.captureStackTrace;
(Error as { captureStackTrace?: unknown }).captureStackTrace =
undefined;
try {
const err = new TestError("TEST.A", "m");
expect(err.code).toBe("TEST.A");
} finally {
(Error as { captureStackTrace?: unknown }).captureStackTrace =
original;
}
});
});

View File

@@ -0,0 +1,57 @@
import { resolveMessage } from "../i18n/resolver.js";
import type {
ErrorContext,
SuaveplanErrorOptions,
} from "./suaveplan-error.types.js";
/**
* Foundation error class for the entire Suaveplan monorepo.
*
* Every non-testing package SHOULD extend this class with a literal-typed
* `TCode` parameter so `instanceof` narrows the code union.
*
* @example
* ```ts
* class AuthError extends SuaveplanError<"AUTH.LOGIN.INVALID" | "AUTH.LOGIN.LOCKED"> {}
* throw new AuthError("AUTH.LOGIN.INVALID", "error.auth.login.invalid");
* ```
*/
export abstract class SuaveplanError<
TCode extends string = string,
> extends Error {
public readonly code: TCode;
public readonly context?: ErrorContext;
public readonly timestamp: number;
public readonly traceId?: string;
protected constructor(
code: TCode,
messageKey: string,
options: SuaveplanErrorOptions = {}
) {
super(resolveMessage(messageKey, options.context), {
cause: options.cause,
});
this.name = new.target.name;
this.code = code;
this.timestamp = (options.clock ?? defaultClock)();
if (options.context !== undefined) {
this.context = options.context;
}
if (options.traceId !== undefined) {
this.traceId = options.traceId;
}
const capture = (Error as ErrorWithCaptureStackTrace).captureStackTrace;
if (typeof capture === "function") {
capture(this, new.target);
}
}
}
interface ErrorWithCaptureStackTrace {
// biome-ignore lint/complexity/noBannedTypes: Node's Error.captureStackTrace signature takes a Function
captureStackTrace?: (target: object, constructorOpt: Function) => void;
}
/** Lives in one place so tests and consumers can swap it via property mock. */
export const defaultClock = (): number => Date.now();

View File

@@ -0,0 +1,17 @@
/** Context map attached to a SuaveplanError. Values may be `Sensitive<T>`. */
export type ErrorContext = Readonly<Record<string, unknown>>;
/** Optional construction options for any `SuaveplanError` subclass. */
export interface SuaveplanErrorOptions {
/** Structured fields attached to the error; redacted at the wire boundary. */
readonly context?: ErrorContext;
/** Underlying cause — preserved for `walkCauses`, serialised in-chain. */
readonly cause?: unknown;
/** Trace correlation id, propagated to `toSpanEvent`. */
readonly traceId?: string;
/**
* Time source. Defaults to `() => Date.now()`. Inject for determinism.
* Never read at module-import time.
*/
readonly clock?: () => number;
}

View File

@@ -0,0 +1 @@
export { type WalkCausesOptions, walkCauses } from "./walk-causes";

View File

@@ -0,0 +1,72 @@
# walk-causes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`walkCauses` is a generator that traverses the `cause` chain of any error-shaped value and yields each link. It exists so logging, serialization, and debugging surfaces can read a full causal history without manual recursion, while staying safe against the two failure modes that plain recursion invites: cycles (A caused B, B caused A) and pathological depth (an attacker-controlled cause chain that exhausts the stack).
## Features
- Generator — consumers can break early or materialise via `Array.from`
- Cycle-safe: tracks visited objects in a `WeakSet`
- Depth-bounded: default `maxDepth` is 32, override via options
- Tolerates non-object causes — a string, number, `null`, or `undefined` cause terminates the walk gracefully
- Zero allocation outside the generator's own state for non-cyclic chains
## Usage: Basic Example
```ts
import { walkCauses } from "@suaveplan/error";
const root = new Error("root");
const wrapped = new Error("wrapped", { cause: root });
for (const link of walkCauses(wrapped)) {
console.log((link as Error).message);
}
// wrapped
// root
```
## Usage: Advanced Example
```ts
import { SuaveplanError, defineCodes, walkCauses } from "@suaveplan/error";
const CODES = defineCodes("DEMO", ["A", "B"] as const);
class DemoError extends SuaveplanError<typeof CODES[keyof typeof CODES]> {
public constructor(
code: typeof CODES[keyof typeof CODES],
key: string,
options?: ConstructorParameters<typeof SuaveplanError>[2]
) {
super(code, key, options);
}
}
const a = new DemoError(CODES.A, "demo.a");
const b = new DemoError(CODES.B, "demo.b", { cause: a });
// Intentional cycle — walker terminates gracefully.
(a as { cause?: unknown }).cause = b;
const links = Array.from(walkCauses(b, { maxDepth: 10 }));
console.log(links.length); // 2, not infinite
```
## API Reference
### `walkCauses(root: unknown, options?: WalkCausesOptions)`
- `root` — any value; the walk starts here
- `options.maxDepth` — maximum number of links to yield (default `MAX_CAUSE_DEPTH` = 32)
- Returns `Generator<unknown, void, void>`
### `interface WalkCausesOptions`
- `readonly maxDepth?: number` — inclusive of the root; values `<= 0` yield nothing
## Implementation Notes
Cycle detection uses `WeakSet.has` BEFORE yielding each object, so a cycle terminates after the second visit rather than producing a duplicate yield. Non-object causes (strings, numbers, `null`) are yielded but not added to the set (`WeakSet` only accepts objects), then the walk stops because a primitive has no `.cause` to descend into. The explicit `<= 0` short-circuit on `maxDepth` is important for callers that want to conditionally disable traversal by passing `0`. The generator form lets callers bail early on the first match without allocating the rest of the chain — important for large logging payloads.

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { walkCauses } from "./walk-causes.js";
describe("walkCauses", () => {
it("yields a linear chain to the leaf", { timeout: 5000 }, () => {
const d: { cause?: unknown } = {};
const c = { cause: d };
const b = { cause: c };
const a = { cause: b };
expect(Array.from(walkCauses(a))).toEqual([a, b, c, d]);
});
it("terminates on self-cycle", { timeout: 5000 }, () => {
const a: { cause?: unknown } = {};
a.cause = a;
const out = Array.from(walkCauses(a));
expect(out).toEqual([a]);
});
it("terminates on mutual cycle", { timeout: 5000 }, () => {
const a: { cause?: unknown } = {};
const b: { cause?: unknown } = { cause: a };
a.cause = b;
expect(Array.from(walkCauses(a))).toEqual([a, b]);
});
it("enforces depth cap", { timeout: 5000 }, () => {
let node: { cause?: unknown } = {};
const root = node;
for (let i = 0; i < 99; i++) {
const next: { cause?: unknown } = {};
node.cause = next;
node = next;
}
const out = Array.from(walkCauses(root, { maxDepth: 10 }));
expect(out.length).toBe(10);
});
it("short-circuits on zero / negative depth", {
timeout: 5000,
}, () => {
expect(Array.from(walkCauses({}, { maxDepth: 0 }))).toEqual([]);
expect(Array.from(walkCauses({}, { maxDepth: -1 }))).toEqual([]);
});
it("terminates at non-object cause", { timeout: 5000 }, () => {
const a = { cause: "stringy" };
expect(Array.from(walkCauses(a))).toEqual([a, "stringy"]);
});
it("terminates at null cause", { timeout: 5000 }, () => {
const a = { cause: null };
expect(Array.from(walkCauses(a))).toEqual([a, null]);
});
it("terminates when cause is undefined", { timeout: 5000 }, () => {
expect(Array.from(walkCauses({}))).toEqual([{}]);
});
it("handles a primitive root", { timeout: 5000 }, () => {
expect(Array.from(walkCauses("boom"))).toEqual(["boom"]);
});
});

View File

@@ -0,0 +1,37 @@
import { MAX_CAUSE_DEPTH } from "../constants/index.js";
export interface WalkCausesOptions {
/** Maximum depth (inclusive of the root). Default: `MAX_CAUSE_DEPTH` (32). */
readonly maxDepth?: number;
}
/**
* Yield each error in the cause chain, starting from `root`.
* Cycle-safe (tracks visited objects via WeakSet) and depth-bounded.
* Non-object causes (strings, numbers, undefined) terminate the walk.
*/
export function* walkCauses(
root: unknown,
options: WalkCausesOptions = {}
): Generator<unknown, void, void> {
const maxDepth = options.maxDepth ?? MAX_CAUSE_DEPTH;
if (maxDepth <= 0) return;
const seen = new WeakSet<object>();
let current: unknown = root;
let depth = 0;
while (depth < maxDepth) {
if (typeof current === "object" && current !== null) {
if (seen.has(current)) return;
seen.add(current);
}
yield current;
depth++;
if (typeof current !== "object" || current === null) return;
const next = (current as { cause?: unknown }).cause;
if (next === undefined) return;
current = next;
}
}

View File

@@ -0,0 +1,66 @@
# define-codes
## Purpose
`defineCodes` is the canonical way to declare a stable, literal-typed error code registry for a package. It takes a namespace string and a tuple of reasons, and returns a frozen object mapping each reason to its fully-qualified code (`<namespace>.<reason>`). Because the return type is inferred from the input tuple, `instanceof` checks and `switch (err.code)` blocks get exhaustiveness checking without any manual enum declarations kept in sync.
## Features
- Pure, deterministic, no side effects
- Full literal-type inference for both keys and values
- `Object.freeze` on the returned record so codes cannot be mutated at runtime
- Works with dotted reasons (`"WIRE.MALFORMED"` yields `"ERROR.WIRE.MALFORMED"`) so packages can subdivide their own namespace
## Usage: Basic Example
```ts
import { defineCodes } from "@suaveplan/error";
const AUTH_CODES = defineCodes("AUTH", ["LOGIN_FAILED", "LOGIN_LOCKED"] as const);
AUTH_CODES.LOGIN_FAILED; // "AUTH.LOGIN_FAILED"
```
## Usage: Advanced Example
```ts
import { defineCodes, SuaveplanError } from "@suaveplan/error";
const PAYMENT_CODES = defineCodes("PAYMENT", [
"CARD.DECLINED",
"CARD.EXPIRED",
"GATEWAY.TIMEOUT",
] as const);
type PaymentCode = typeof PAYMENT_CODES[keyof typeof PAYMENT_CODES];
class PaymentError extends SuaveplanError<PaymentCode> {
public constructor(
code: PaymentCode,
key: string,
options?: ConstructorParameters<typeof SuaveplanError>[2]
) {
super(code, key, options);
}
}
function explain(err: PaymentError): string {
switch (err.code) {
case "PAYMENT.CARD.DECLINED": return "The card was declined.";
case "PAYMENT.CARD.EXPIRED": return "The card has expired.";
case "PAYMENT.GATEWAY.TIMEOUT": return "The gateway timed out.";
}
}
```
## API Reference
### `defineCodes<NS extends string, R extends readonly string[]>(ns, reasons)`
- `ns` — the namespace, conventionally the package identifier in uppercase (`"EVENTS"`, `"AUTH"`)
- `reasons` — a `readonly string[]` tuple (always pass `as const` so the literal types survive)
- Returns `{ readonly [K in R[number]]: \`${NS}.${K}\` }` — frozen
## Implementation Notes
The returned record is built via `Object.create(null)` so it has no prototype chain — nothing leaks through from `Object.prototype`, and any accidental `.hasOwnProperty` access returns `undefined` rather than a method. The freeze is a runtime belt-and-braces measure; TypeScript's `readonly` already prevents assignment at compile time, but `Object.freeze` catches runtime mutations from untyped callers. The `reasons` tuple must be passed as `as const` in TypeScript for inference to work; without it, `R` widens to `string[]` and the values collapse to `string`. The helper is intentionally the only place that constructs these records so the entire project shares one audit surface.

View File

@@ -0,0 +1,20 @@
/**
* Spec-conformance tests for `defineCodes`. Pins the type-level scenario
* "Code values are literal-typed" from `openspec/specs/error/spec.md`.
*/
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { defineCodes } from "./define-codes.js";
describe("defineCodes — literal-typed values", () => {
it("code values are literal-typed (e.g. `PKG.A`, not `string`)", {
timeout: 5000,
}, () => {
const codes = defineCodes("PKG", ["A", "B"] as const);
// Compile-time: literal types preserved through registry.
expectTypeOf(codes.A).toEqualTypeOf<"PKG.A">();
expectTypeOf(codes.B).toEqualTypeOf<"PKG.B">();
// Runtime sanity.
expect(codes.A).toBe("PKG.A");
expect(codes.B).toBe("PKG.B");
});
});

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { defineCodes } from "./define-codes.js";
import { ERROR_CODES } from "./error-codes.js";
describe("defineCodes", () => {
it("prefixes every reason with the namespace", { timeout: 5000 }, () => {
const codes = defineCodes("PKG", ["A", "B"] as const);
expect(codes.A).toBe("PKG.A");
expect(codes.B).toBe("PKG.B");
});
it("freezes the returned record", { timeout: 5000 }, () => {
const codes = defineCodes("X", ["Y"] as const);
expect(Object.isFrozen(codes)).toBe(true);
expect(() => {
(codes as { Y: string }).Y = "X.Z";
}).toThrow();
});
it("covers nested namespaces like domain.reason", {
timeout: 5000,
}, () => {
expect(ERROR_CODES["WIRE.MALFORMED"]).toBe("ERROR.WIRE.MALFORMED");
});
});

View File

@@ -0,0 +1,22 @@
/**
* Build a frozen record mapping each reason to a fully-qualified code string
* of the form `<namespace>.<reason>`.
*
* The return type is fully inferred: `defineCodes("AUTH", ["LOGIN_FAILED"])`
* yields `{ readonly LOGIN_FAILED: "AUTH.LOGIN_FAILED" }`.
*/
export function defineCodes<
Namespace extends string,
Reasons extends readonly string[],
>(
namespace: Namespace,
reasons: Reasons
): { readonly [K in Reasons[number]]: `${Namespace}.${K}` } {
const out = Object.create(null) as Record<string, string>;
for (const reason of reasons) {
out[reason] = `${namespace}.${reason}`;
}
return Object.freeze(out) as {
readonly [K in Reasons[number]]: `${Namespace}.${K}`;
};
}

View File

@@ -0,0 +1,70 @@
# error-codes
## Purpose
`ERROR_CODES` is the package-local registry for `@suaveplan/error` itself. It enumerates every failure mode the foundation package can raise — wire-format validation failures, binary codec failures, and cause-chain depth violations — using the shared `defineCodes` helper so the shape matches every other package in the project. Consumers typically import these codes to match on them inside a `try`/`catch` around `fromJSON`, `fromBinary`, or `walkCauses`.
## Features
- Frozen registry of package-internal codes, all prefixed `ERROR.`
- Literal-typed values via `defineCodes`
- `ErrorCode` type alias for the union of all values
- Extends without renumbering — adding a new reason never changes an existing code string
## Usage: Basic Example
```ts
import { ERROR_CODES } from "@suaveplan/error";
ERROR_CODES["WIRE.MALFORMED"]; // "ERROR.WIRE.MALFORMED"
```
## Usage: Advanced Example
```ts
import {
ERROR_CODES,
ErrorPackageError,
fromJSON,
type ErrorCode,
} from "@suaveplan/error";
function isMalformed(err: unknown): err is ErrorPackageError {
return (
err instanceof ErrorPackageError &&
err.code === ERROR_CODES["WIRE.MALFORMED"]
);
}
function explain(code: ErrorCode): string {
switch (code) {
case "ERROR.WIRE.MALFORMED":
return "The payload did not match the wire schema.";
case "ERROR.WIRE.UNSUPPORTED_VERSION":
return "The payload is from a newer wire format than this package supports.";
case "ERROR.BINARY.OUT_OF_RANGE":
return "A numeric field exceeded the encoder's supported range.";
case "ERROR.BINARY.TRUNCATED":
return "The binary payload ended before decoding completed.";
case "ERROR.BINARY.UNSUPPORTED_TYPE":
return "The binary payload contained a type the decoder does not recognise.";
case "ERROR.CAUSE.DEPTH_EXCEEDED":
return "A cause chain exceeded the configured maximum depth.";
}
}
```
## API Reference
### `ERROR_CODES`
- Frozen record mapping reason keys to fully-qualified codes
- Keys: `"WIRE.MALFORMED"`, `"WIRE.UNSUPPORTED_VERSION"`, `"BINARY.OUT_OF_RANGE"`, `"BINARY.TRUNCATED"`, `"BINARY.UNSUPPORTED_TYPE"`, `"CAUSE.DEPTH_EXCEEDED"`
### `type ErrorCode`
Union of all `ERROR_CODES[K]` string literals. Used to narrow `ErrorPackageError["code"]`.
## Implementation Notes
The registry is intentionally minimal — it only covers failure modes generated by `@suaveplan/error`'s own code paths. Every other package builds its own registry with `defineCodes` and its own namespace (`EVENTS`, `AUTH`, etc.). Codes are never renumbered or reused: removal of a code requires a deprecation window where the old value remains exported. `ErrorCode` is derived from the runtime values, so the type and runtime stay aligned without manual type declarations to keep in sync.

View File

@@ -0,0 +1,13 @@
import { defineCodes } from "./define-codes.js";
/** Package-local error codes for `@suaveplan/error` itself. */
export const ERROR_CODES = defineCodes("ERROR", [
"WIRE.MALFORMED",
"WIRE.UNSUPPORTED_VERSION",
"BINARY.OUT_OF_RANGE",
"BINARY.TRUNCATED",
"BINARY.UNSUPPORTED_TYPE",
"CAUSE.DEPTH_EXCEEDED",
] as const);
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];

View File

@@ -0,0 +1,2 @@
export { defineCodes } from "./define-codes";
export { ERROR_CODES, type ErrorCode } from "./error-codes";

View File

@@ -0,0 +1,173 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ErrorPackageError } from "./base/package-error.js";
import { ERROR_CODES } from "./codes/error-codes.js";
import { markSensitive, unwrapSensitive } from "./redact/sensitive.js";
import { fromJSON } from "./serialize/from-json.js";
import { toJSON } from "./serialize/to-json.js";
const TIMEOUT = { timeout: 5000 } as const;
function makeError(
context?: Readonly<Record<string, unknown>>
): ErrorPackageError {
return new ErrorPackageError(
ERROR_CODES["WIRE.MALFORMED"],
"error.test",
context !== undefined ? { context } : undefined
);
}
describe("error spec conformance", () => {
describe("Recursive redaction", () => {
it(
"redacts sensitive values nested inside sub-objects",
TIMEOUT,
() => {
const err = makeError({
user: { profile: { token: markSensitive("abc") } },
});
const wire = toJSON(err);
const ctx = wire.context as {
user: { profile: { token: string } };
};
expect(ctx.user.profile.token).toBe("[REDACTED]");
}
);
it("redacts sensitive values inside arrays", TIMEOUT, () => {
const err = makeError({
tokens: [markSensitive("a"), markSensitive("b"), "plain"],
});
const wire = toJSON(err);
const ctx = wire.context as { tokens: unknown[] };
expect(ctx.tokens[0]).toBe("[REDACTED]");
expect(ctx.tokens[1]).toBe("[REDACTED]");
expect(ctx.tokens[2]).toBe("plain");
});
it(
"terminates on cyclic context without throwing or looping",
TIMEOUT,
() => {
const ctx: Record<string, unknown> = { name: "alice" };
ctx["self"] = ctx;
const err = makeError(ctx as Readonly<Record<string, unknown>>);
const wire = toJSON(err);
expect(wire.context).toBeDefined();
const reduced = wire.context as { name: string };
expect(reduced.name).toBe("alice");
}
);
it("leaves in-memory context untouched", TIMEOUT, () => {
const tagged = markSensitive("abc");
const err = makeError({ token: tagged });
expect(
unwrapSensitive(err.context?.["token"] as typeof tagged)
).toBe("abc");
});
});
describe("TruncatedCause sentinel", () => {
it("emits sentinel when maxDepth is exceeded", TIMEOUT, () => {
const a = makeError();
const b = makeError();
const c = makeError();
(a as unknown as { cause: unknown }).cause = b;
(b as unknown as { cause: unknown }).cause = c;
const wire = toJSON(a, { maxDepth: 2 });
expect(wire.cause).toBeDefined();
expect(wire.cause?.cause?.code).toBe("ERROR.CAUSE.TRUNCATED");
expect(wire.cause?.cause?.name).toBe("TruncatedCause");
});
it("emits sentinel when a cycle is detected", TIMEOUT, () => {
const a = makeError();
const b = makeError();
(a as unknown as { cause: unknown }).cause = b;
(b as unknown as { cause: unknown }).cause = a;
const wire = toJSON(a);
expect(wire.cause?.cause?.code).toBe("ERROR.CAUSE.TRUNCATED");
});
it("sentinel survives round-trip through fromJSON", TIMEOUT, () => {
const a = makeError();
const b = makeError();
(a as unknown as { cause: unknown }).cause = b;
(b as unknown as { cause: unknown }).cause = a;
const wire = toJSON(a);
const reconstructed = fromJSON(wire);
expect(reconstructed.cause?.cause?.code).toBe(
"ERROR.CAUSE.TRUNCATED"
);
expect(reconstructed.cause?.cause?.name).toBe("TruncatedCause");
});
});
describe("Wire format version discriminator", () => {
it("preserves __suaveplanError: 1 through round-trip", TIMEOUT, () => {
const err = makeError();
const wire = toJSON(err);
const reconstructed = fromJSON(wire);
expect(reconstructed.__suaveplanError).toBe(1);
});
it("rejects payload missing __suaveplanError", TIMEOUT, () => {
const bad = {
code: "X.Y.Z",
message: "m",
timestamp: 0,
name: "E",
};
expect(() => fromJSON(bad)).toThrow();
try {
fromJSON(bad);
} catch (err) {
expect((err as { code: string }).code).toBe(
"ERROR.WIRE.MALFORMED"
);
}
});
it(
"rejects payload with wrong __suaveplanError version",
TIMEOUT,
() => {
const bad = {
__suaveplanError: 2,
code: "X.Y.Z",
message: "m",
timestamp: 0,
name: "E",
};
expect(() => fromJSON(bad)).toThrow();
try {
fromJSON(bad);
} catch (err) {
expect((err as { code: string }).code).toBe(
"ERROR.WIRE.MALFORMED"
);
}
}
);
});
describe("fromJSON returns POJO discriminator (base class abstract)", () => {
it(
"reconstructed value is structurally complete but not an Error instance",
TIMEOUT,
() => {
const err = makeError({ key: "value" });
const wire = toJSON(err);
const reconstructed = fromJSON(wire);
expect(reconstructed.__suaveplanError).toBe(1);
expect(reconstructed.code).toBe(wire.code);
expect(reconstructed.message).toBe(wire.message);
expect(reconstructed.context).toEqual(wire.context);
expect(reconstructed.timestamp).toBe(wire.timestamp);
expect(reconstructed.name).toBe(wire.name);
expect(reconstructed instanceof Error).toBe(false);
}
);
});
});

View File

@@ -0,0 +1,8 @@
/** Maximum depth for cause-chain traversal and serialization. */
export const MAX_CAUSE_DEPTH = 32;
/** String placed on the wire where a redacted value used to be. */
export const REDACTED_LABEL = "[REDACTED]";
/** Current version of the wire format produced by `toJSON` / `toBinary`. */
export const WIRE_VERSION = 1;

View File

@@ -0,0 +1,6 @@
export {
clearMessageResolver,
type MessageResolver,
resolveMessage,
setMessageResolver,
} from "./resolver";

View File

@@ -0,0 +1,83 @@
# resolver
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The i18n resolver hook lets an application plug a translator into every `SuaveplanError`'s message formatting without coupling this foundation package to any i18n framework. Error subclasses throw with i18n keys (`"error.auth.login.invalid"`); if no resolver is installed, `resolveMessage` returns the key verbatim so the error is still readable. When the app bootstraps with a locale catalog, it calls `setMessageResolver` once and from that point every error's `message` is translated.
## Features
- Single module-scope resolver slot — explicit, not a registry of resolvers
- `setMessageResolver` returns the previous resolver so layered installs are reversible
- `clearMessageResolver` restores the default pass-through behaviour
- No i18n framework imported — caller supplies whatever translator they use
- Params optional — resolver signature is `(key, params?) => string`
## Usage: Basic Example
```ts
import { setMessageResolver, clearMessageResolver, resolveMessage } from "@suaveplan/error";
setMessageResolver((key) => `[en] ${key}`);
resolveMessage("error.auth.login.invalid");
// "[en] error.auth.login.invalid"
clearMessageResolver();
resolveMessage("error.auth.login.invalid");
// "error.auth.login.invalid"
```
## Usage: Advanced Example
```ts
import { setMessageResolver, SuaveplanError, defineCodes } from "@suaveplan/error";
import messages_en from "./locales/en.json";
import messages_fr from "./locales/fr.json";
function installLocale(locale: "en" | "fr"): void {
const catalogue = locale === "en" ? messages_en : messages_fr;
setMessageResolver((key, params) => {
const template = (catalogue as Record<string, string>)[key] ?? key;
return params === undefined
? template
: template.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? ""));
});
}
const CODES = defineCodes("AUTH", ["LOGIN.INVALID"] as const);
class AuthError extends SuaveplanError<typeof CODES["LOGIN.INVALID"]> {
public constructor(key: string, options?: ConstructorParameters<typeof SuaveplanError>[2]) {
super(CODES["LOGIN.INVALID"], key, options);
}
}
installLocale("fr");
const err = new AuthError("error.auth.login.invalid", { context: { user: "alice" } });
console.log(err.message);
```
## API Reference
### `setMessageResolver(resolver: MessageResolver): MessageResolver | undefined`
Install `resolver`. Returns the previously installed resolver, or `undefined` on first install.
### `clearMessageResolver(): void`
Remove any installed resolver. `resolveMessage` returns keys verbatim afterwards.
### `resolveMessage(key: string, params?: Readonly<Record<string, unknown>>): string`
Resolve `key` through the installed resolver, or return it unchanged if none is installed.
### `type MessageResolver`
```ts
(key: string, params?: Readonly<Record<string, unknown>>) => string
```
## Implementation Notes
Module-scope state is a single `let` — intentionally shared across consumers in the process. Test isolation comes from calling `clearMessageResolver()` in `afterEach`, which is how this package's own tests work. The resolver is invoked during `super()` in every `SuaveplanError` subclass, so the `message` that ends up on `err` is whatever the resolver returns at throw time. That means switching locales mid-process only affects errors thrown afterwards — already-thrown errors retain their original rendered message.

View File

@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
clearMessageResolver,
resolveMessage,
setMessageResolver,
} from "./resolver.js";
afterEach(() => clearMessageResolver());
describe("message resolver", () => {
it("returns the key verbatim when no resolver is installed", {
timeout: 5000,
}, () => {
expect(resolveMessage("a.b.c")).toBe("a.b.c");
});
it("applies an installed resolver", { timeout: 5000 }, () => {
setMessageResolver((key) => `[${key}]`);
expect(resolveMessage("x")).toBe("[x]");
});
it("passes params through to the resolver", { timeout: 5000 }, () => {
setMessageResolver((key, params) => `${key}:${params?.["n"] ?? 0}`);
expect(resolveMessage("k", { n: 9 })).toBe("k:9");
});
it("setMessageResolver returns the previous resolver", {
timeout: 5000,
}, () => {
const first = () => "one";
const second = () => "two";
setMessageResolver(first);
const prev = setMessageResolver(second);
expect(prev).toBe(first);
expect(resolveMessage("_")).toBe("two");
});
it("clearMessageResolver returns to default", { timeout: 5000 }, () => {
setMessageResolver(() => "x");
clearMessageResolver();
expect(resolveMessage("k")).toBe("k");
});
it("setMessageResolver returns undefined on first install", {
timeout: 5000,
}, () => {
const prev = setMessageResolver(() => "first");
expect(prev).toBeUndefined();
});
});

View File

@@ -0,0 +1,30 @@
import type { MessageResolver } from "@suaveplan/types";
export type { MessageResolver };
let activeResolver: MessageResolver | undefined;
/** Install a global message resolver. Returns the previous resolver, if any. */
export function setMessageResolver(
resolver: MessageResolver
): MessageResolver | undefined {
const prev = activeResolver;
activeResolver = resolver;
return prev;
}
/** Remove any installed resolver — subsequent lookups return the key. */
export function clearMessageResolver(): void {
activeResolver = undefined;
}
/**
* Resolve a message key to its localized string. With no resolver installed,
* the key is returned verbatim so the error still has a non-empty message.
*/
export function resolveMessage(
key: string,
params?: Readonly<Record<string, unknown>>
): string {
return activeResolver !== undefined ? activeResolver(key, params) : key;
}

View File

@@ -0,0 +1,46 @@
// Base
export { ErrorPackageError } from "./base/package-error.js";
export { defaultClock, SuaveplanError } from "./base/suaveplan-error.js";
export type {
ErrorContext,
SuaveplanErrorOptions,
} from "./base/suaveplan-error.types.js";
// Causes
export { type WalkCausesOptions, walkCauses } from "./causes/walk-causes.js";
// Codes
export { defineCodes } from "./codes/define-codes.js";
export { ERROR_CODES, type ErrorCode } from "./codes/error-codes.js";
// Constants
export {
MAX_CAUSE_DEPTH,
REDACTED_LABEL,
WIRE_VERSION,
} from "./constants/index.js";
// i18n
export {
clearMessageResolver,
type MessageResolver,
resolveMessage,
setMessageResolver,
} from "./i18n/resolver.js";
// OTel
export { type SpanEvent, toSpanEvent } from "./otel/to-span-event.js";
// Redact
export {
isSensitive,
markSensitive,
type Sensitive,
unwrapSensitive,
} from "./redact/sensitive.js";
// Serialize
export {
fromBinary,
fromJSON,
type ReconstructedError,
type ToJsonOptions,
toBinary,
toJSON,
type WireError,
} from "./serialize/index.js";

View File

@@ -0,0 +1 @@
export { type SpanEvent, toSpanEvent } from "./to-span-event";

View File

@@ -0,0 +1,78 @@
# to-span-event
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`toSpanEvent` translates any error-shaped value into the attribute payload that the OpenTelemetry specification defines for an `exception` span event. It gives tracing code a one-line call to attach a failure to a span without importing any `@opentelemetry/*` SDK. The caller decides how the returned payload is handed to the actual OTel recorder, which keeps `@suaveplan/error` dep-free.
## Features
- Pure function, no side effects
- No `@opentelemetry/*` import — just the shape OTel consumers expect
- Tolerates any value: `SuaveplanError`, plain `Error`, `Object.create(null)`, primitives via coercion
- Fills every required OTel attribute (`exception.type`, `exception.message`, `exception.stacktrace`, `exception.code`)
- Includes `exception.traceId` only when the error carries one
## Usage: Basic Example
```ts
import { toSpanEvent, ErrorPackageError, ERROR_CODES } from "@suaveplan/error";
const err = new ErrorPackageError(
ERROR_CODES["WIRE.MALFORMED"],
"error.error.wire.malformed"
);
const event = toSpanEvent(err);
// { name: "exception", attributes: { "exception.type": "ErrorPackageError", ... } }
```
## Usage: Advanced Example
```ts
import { trace, SpanStatusCode } from "@opentelemetry/api";
import { toSpanEvent } from "@suaveplan/error";
function recordException(spanName: string, fn: () => void): void {
const tracer = trace.getTracer("my-service");
tracer.startActiveSpan(spanName, (span) => {
try {
fn();
span.setStatus({ code: SpanStatusCode.OK });
} catch (err) {
const ev = toSpanEvent(err);
span.addEvent(ev.name, ev.attributes);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
});
}
```
## API Reference
### `toSpanEvent(err: unknown): SpanEvent`
Returns `{ name: "exception", attributes }`. Attributes:
- `"exception.type"` — `err.constructor.name`, falling back to `err.name`, then `"Error"`
- `"exception.message"` — `err.message` if a string, else `String(err.message ?? "")`
- `"exception.stacktrace"` — `err.stack` if a string, else `""`
- `"exception.code"` — `err.code` if a string, else `"UNKNOWN"`
- `"exception.traceId"` — present only when `err.traceId` is a string
### `interface SpanEvent`
```ts
{
readonly name: "exception";
readonly attributes: Readonly<Record<string, string>>;
}
```
## Implementation Notes
`toSpanEvent` never throws — every lookup is guarded with a `typeof` check before coercion. Values with no discoverable type name fall back to `"Error"`, values with no stack fall back to `""`, codeless values fall back to `"UNKNOWN"` — all of which match OpenTelemetry's published guidance for unknown failures. The function reads `err.constructor.name` first because subclasses of `SuaveplanError` get a useful class name that way, even when `err.name` defaults to the base's string. No OpenTelemetry SDK is imported; the returned shape matches `Span.addEvent`'s expected arguments exactly so consumers can spread or forward it.

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ErrorPackageError } from "../base/package-error.js";
import { ERROR_CODES } from "../codes/error-codes.js";
import { toSpanEvent } from "./to-span-event.js";
describe("toSpanEvent", () => {
it("maps a SuaveplanError to exception-event attributes", {
timeout: 5000,
}, () => {
const err = new ErrorPackageError(
ERROR_CODES["WIRE.MALFORMED"],
"error.error.wire.malformed",
{ traceId: "t-1" }
);
const ev = toSpanEvent(err);
expect(ev.name).toBe("exception");
expect(ev.attributes["exception.type"]).toBe("ErrorPackageError");
expect(ev.attributes["exception.code"]).toBe("ERROR.WIRE.MALFORMED");
expect(ev.attributes["exception.traceId"]).toBe("t-1");
expect(typeof ev.attributes["exception.stacktrace"]).toBe("string");
});
it("falls back to empty stack and UNKNOWN code for bare values", {
timeout: 5000,
}, () => {
const ev = toSpanEvent({});
expect(ev.attributes["exception.type"]).toBe("Object");
expect(ev.attributes["exception.stacktrace"]).toBe("");
expect(ev.attributes["exception.code"]).toBe("UNKNOWN");
expect(ev.attributes["exception.traceId"]).toBeUndefined();
});
it("uses err.name when the constructor name is missing", {
timeout: 5000,
}, () => {
const bare = Object.create(null) as Record<string, unknown>;
bare["name"] = "CustomName";
bare["message"] = "m";
const ev = toSpanEvent(bare);
expect(ev.attributes["exception.type"]).toBe("CustomName");
});
it("falls back to 'Error' when neither constructor.name nor name is present", {
timeout: 5000,
}, () => {
const bare = Object.create(null) as Record<string, unknown>;
const ev = toSpanEvent(bare);
expect(ev.attributes["exception.type"]).toBe("Error");
});
it("stringifies non-string messages", { timeout: 5000 }, () => {
const ev = toSpanEvent({ message: 42 });
expect(ev.attributes["exception.message"]).toBe("42");
});
it("coerces nullish message to empty string", { timeout: 5000 }, () => {
const ev = toSpanEvent({ message: undefined });
expect(ev.attributes["exception.message"]).toBe("");
});
});

View File

@@ -0,0 +1,49 @@
export interface SpanEvent {
readonly name: "exception";
readonly attributes: {
readonly "exception.type": string;
readonly "exception.message": string;
readonly "exception.stacktrace": string;
readonly "exception.code": string;
readonly "exception.traceId"?: string;
};
}
/**
* Translate any error-shaped value to the OpenTelemetry `exception` span
* event payload. No OTel SDK is imported — just the attribute shape.
*/
export function toSpanEvent(err: unknown): SpanEvent {
const src = err as {
constructor?: { name?: string };
name?: string;
message?: unknown;
stack?: unknown;
code?: unknown;
traceId?: unknown;
};
const typeName =
(typeof src.constructor?.name === "string" && src.constructor.name) ||
(typeof src.name === "string" && src.name) ||
"Error";
const attributes: {
"exception.type": string;
"exception.message": string;
"exception.stacktrace": string;
"exception.code": string;
"exception.traceId"?: string;
} = {
"exception.type": typeName,
"exception.message":
typeof src.message === "string"
? src.message
: String(src.message ?? ""),
"exception.stacktrace": typeof src.stack === "string" ? src.stack : "",
"exception.code": typeof src.code === "string" ? src.code : "UNKNOWN",
};
if (typeof src.traceId === "string") {
attributes["exception.traceId"] = src.traceId;
}
return { name: "exception", attributes };
}

View File

@@ -0,0 +1,6 @@
export {
isSensitive,
markSensitive,
type Sensitive,
unwrapSensitive,
} from "./sensitive";

View File

@@ -0,0 +1,76 @@
# sensitive
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `sensitive` module provides the small primitive that controls redaction at the serialization boundary. `markSensitive` wraps a value so that when the error is serialised via `toJSON` or `toBinary`, the field is replaced by the literal string `"[REDACTED]"` — while in-memory access through `err.context` continues to see the real value. This gives debugger sessions the full picture without letting secrets leak into logs, tracing sinks, or transport payloads.
## Features
- Pure, no module-scope state
- Tag uses `Symbol.for("@suaveplan/error.sensitive")` so the same tag is recognised across bundle boundaries and duplicated module copies
- `isSensitive` is a type guard — narrows to `Sensitive<T>` inside the true branch
- `unwrapSensitive` is safe to call on non-sensitive values (pass-through)
- Wire redaction applied uniformly by `toJSON` — consumers don't have to remember to do it
## Usage: Basic Example
```ts
import { markSensitive, unwrapSensitive } from "@suaveplan/error";
const ctx = { user: "alice", token: markSensitive("tkn_xyz") };
unwrapSensitive(ctx.token); // "tkn_xyz" — in-memory access unchanged
```
## Usage: Advanced Example
```ts
import {
markSensitive,
SuaveplanError,
defineCodes,
toJSON,
} from "@suaveplan/error";
const CODES = defineCodes("AUTH", ["LOGIN.INVALID"] as const);
class AuthError extends SuaveplanError<typeof CODES["LOGIN.INVALID"]> {
public constructor(key: string, options?: ConstructorParameters<typeof SuaveplanError>[2]) {
super(CODES["LOGIN.INVALID"], key, options);
}
}
const err = new AuthError("error.auth.login.invalid", {
context: {
user: "alice",
apiKey: markSensitive("sk_live_..."),
providerToken: markSensitive({ access: "x", refresh: "y" }),
},
});
toJSON(err).context;
// { user: "alice", apiKey: "[REDACTED]", providerToken: "[REDACTED]" }
```
## API Reference
### `markSensitive<T>(value: T): Sensitive<T>`
Wrap `value` with the redaction tag. The wrapper preserves the value unchanged — downstream consumers who explicitly unwrap still see the original.
### `unwrapSensitive<T>(value: Sensitive<T> | T): T`
Return the original value if `value` is tagged; otherwise return `value` unchanged. Always safe.
### `isSensitive<T>(value: unknown): value is Sensitive<T>`
Type guard — `true` when `value` carries the redaction tag.
### `interface Sensitive<T>`
Opaque tagged wrapper; not intended to be constructed by hand — always go through `markSensitive`.
## Implementation Notes
The tag is a `Symbol.for` (global symbol registry) rather than a local `Symbol()` because tests, workers, and even separate bundles can roundtrip a `Sensitive<T>` reliably. Primitive wrappers cost one object allocation per marked value; callers that mark large objects don't pay a deep-clone tax because the wrapper holds the reference, not a copy. The wire boundary (`toJSON`/`toBinary`) is the only place the package writes `"[REDACTED]"` — if a consumer has a bespoke serializer, they are responsible for using `isSensitive` there too.

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { isSensitive, markSensitive, unwrapSensitive } from "./sensitive.js";
describe("sensitive", () => {
it("markSensitive wraps a value", { timeout: 5000 }, () => {
const s = markSensitive("secret");
expect(isSensitive(s)).toBe(true);
});
it("unwrapSensitive returns the original value", {
timeout: 5000,
}, () => {
const s = markSensitive({ token: "abc" });
expect(unwrapSensitive(s)).toEqual({ token: "abc" });
});
it("unwrapSensitive is pass-through for non-sensitive values", {
timeout: 5000,
}, () => {
expect(unwrapSensitive("plain")).toBe("plain");
expect(unwrapSensitive(42)).toBe(42);
});
it("isSensitive rejects null, primitives, and plain objects", {
timeout: 5000,
}, () => {
expect(isSensitive(null)).toBe(false);
expect(isSensitive(undefined)).toBe(false);
expect(isSensitive("x")).toBe(false);
expect(isSensitive({ value: "x" })).toBe(false);
});
});

View File

@@ -0,0 +1,26 @@
const SENSITIVE_TAG = Symbol.for("@suaveplan/error.sensitive");
/** Marker shape produced by `markSensitive`. Opaque to consumers. */
export interface Sensitive<T> {
readonly [SENSITIVE_TAG]: true;
readonly value: T;
}
/** Wrap a value so the serialization boundary will redact it on the wire. */
export function markSensitive<T>(value: T): Sensitive<T> {
return { [SENSITIVE_TAG]: true, value };
}
/** Return the original value inside a `Sensitive<T>`, or the value itself. */
export function unwrapSensitive<T>(value: Sensitive<T> | T): T {
return isSensitive(value) ? value.value : value;
}
/** Type guard for the `Sensitive<T>` tag. */
export function isSensitive<T>(value: unknown): value is Sensitive<T> {
return (
typeof value === "object" &&
value !== null &&
(value as { [SENSITIVE_TAG]?: boolean })[SENSITIVE_TAG] === true
);
}

View File

@@ -0,0 +1,58 @@
# from-binary
## Purpose
`fromBinary` is the decode counterpart to `toBinary`. It accepts a `Uint8Array` produced by `toBinary` (or any compatible encoder that emits UTF-8 JSON matching `wireErrorSchema`) and returns a `ReconstructedError`. Non-UTF-8 input and invalid-JSON input both surface as `ErrorPackageError("ERROR.BINARY.TRUNCATED")` with the original parser failure preserved as `cause`.
## Features
- Strict UTF-8 decode — uses `TextDecoder("utf-8", { fatal: true })` so silently-corrupt bytes raise instead of producing garbage
- Two-stage failure detection: UTF-8 failure → `BINARY.TRUNCATED` with `TextDecoder` cause; JSON-parse failure → `BINARY.TRUNCATED` with `SyntaxError` cause; schema failure → `WIRE.MALFORMED` via `fromJSON`
- Works with Node `Buffer` (subclass of `Uint8Array`)
- Pure, no side effects
## Usage: Basic Example
```ts
import { toBinary, fromBinary } from "@suaveplan/error";
const bytes = toBinary(new Error("boom"));
const reconstituted = fromBinary(bytes);
reconstituted.message; // "boom"
```
## Usage: Advanced Example
```ts
import { fromBinary, ErrorPackageError, ERROR_CODES } from "@suaveplan/error";
async function handleFrame(bytes: Uint8Array): Promise<void> {
try {
const err = fromBinary(bytes);
metrics.errorByCode.increment({ code: err.code });
} catch (err) {
if (
err instanceof ErrorPackageError &&
err.code === ERROR_CODES["BINARY.TRUNCATED"]
) {
// Retryable: likely a partial frame.
await requeue(bytes);
return;
}
throw err;
}
}
```
## API Reference
### `fromBinary(bytes: Uint8Array): ReconstructedError`
- `bytes` — UTF-8 bytes produced by `toBinary` or equivalent encoder
- Returns a `ReconstructedError` (same shape as `fromJSON`)
- Throws `ErrorPackageError("ERROR.BINARY.TRUNCATED")` on decode/parse failure; `cause` is the original error
- Throws `ErrorPackageError("ERROR.WIRE.MALFORMED")` on schema failure (bubbled from `fromJSON`)
## Implementation Notes
The `fatal: true` flag on `TextDecoder` matters — without it, corrupt bytes would be silently replaced with U+FFFD and the downstream `JSON.parse` would produce a valid-looking but subtly wrong envelope. Both failure paths share the same error code (`BINARY.TRUNCATED`) because the caller's recovery strategy is the same (retry/requeue) regardless of whether the bytes were non-UTF-8 or non-JSON. The separation from `WIRE.MALFORMED` is intentional: schema failures indicate a version or sender-side bug, not a transport issue.

View File

@@ -0,0 +1,31 @@
import { ErrorPackageError } from "../base/package-error.js";
import { ERROR_CODES } from "../codes/error-codes.js";
import { fromJSON, type ReconstructedError } from "./from-json.js";
/**
* Decode a binary payload produced by `toBinary`. Truncated or non-UTF-8
* input rejects with `ErrorPackageError("ERROR.BINARY.TRUNCATED")`.
*/
export function fromBinary(bytes: Uint8Array): ReconstructedError {
let json: string;
try {
json = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch (cause) {
throw new ErrorPackageError(
ERROR_CODES["BINARY.TRUNCATED"],
"error.error.binary.truncated",
{ cause }
);
}
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch (cause) {
throw new ErrorPackageError(
ERROR_CODES["BINARY.TRUNCATED"],
"error.error.binary.truncated",
{ cause }
);
}
return fromJSON(parsed);
}

View File

@@ -0,0 +1,70 @@
# from-json
## Purpose
`fromJSON` is the decode counterpart to `toJSON`. It Zod-validates the incoming payload against `wireErrorSchema` and reconstructs a plain object that mirrors the original error's public fields (`code`, `message`, `context`, `timestamp`, `traceId`, `stack`, `name`, and the recursive `cause`). Malformed input raises `ErrorPackageError("ERROR.WIRE.MALFORMED")` so the caller can distinguish parser failures from legitimate domain errors.
## Features
- Schema-validated — rejects any payload whose shape or types don't match `wireErrorSchema`
- Recursive — reconstructs the full `cause` chain
- Pure, deterministic — does NOT re-throw the decoded value, only returns a plain object (callers who want `throw` must wrap)
- Does NOT reconstruct `SuaveplanError` subclasses — the returned object is structurally identical but not `instanceof` the original class (by design — the sender's class may not exist in the receiver's bundle)
- Optional fields absent from the wire are absent from the output — never `undefined` keys
## Usage: Basic Example
```ts
import { fromJSON } from "@suaveplan/error";
const wire = {
__suaveplanError: 1 as const,
code: "AUTH.LOGIN.INVALID",
message: "error.auth.login.invalid",
timestamp: 0,
name: "AuthError",
};
const err = fromJSON(wire);
// err.code === "AUTH.LOGIN.INVALID"
```
## Usage: Advanced Example
```ts
import { fromJSON, toJSON, ErrorPackageError, ERROR_CODES } from "@suaveplan/error";
function decodeFromRemote(body: string): ReturnType<typeof fromJSON> {
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch (cause) {
throw new ErrorPackageError(
ERROR_CODES["WIRE.MALFORMED"],
"error.error.wire.malformed",
{ cause, context: { body } }
);
}
return fromJSON(parsed);
}
// Round-trip sanity check across a transport.
const original = toJSON(someLocalError);
const reconstituted = fromJSON(JSON.parse(JSON.stringify(original)));
console.assert(reconstituted.code === original.code);
```
## API Reference
### `fromJSON(value: unknown): ReconstructedError`
- `value` — any wire payload; must conform to `wireErrorSchema`
- Returns the reconstructed plain object
- Throws `ErrorPackageError("ERROR.WIRE.MALFORMED")` on schema failure; the `cause` is the underlying `ZodError`
### `interface ReconstructedError`
Same shape as `WireError` — `code`, `message`, `timestamp`, `name`, plus optional `context`, `traceId`, `stack`, `cause`.
## Implementation Notes
Deliberately does not resurrect a specific subclass — doing so would require the receiver to have every sender-side subclass imported, which is impossible across service boundaries. Callers who need to re-throw the reconstructed error can wrap it in a known local subclass using the decoded `code` to pick which class to construct. `fromJSON` is recursive: the Zod schema is `z.lazy` so deeply-nested cause chains validate in one pass. The `ErrorPackageError` thrown on malformed input always preserves the original `ZodError` as `cause`, so debugging tools show the exact field that mismatched.

View File

@@ -0,0 +1,56 @@
import { ErrorPackageError } from "../base/package-error.js";
import { ERROR_CODES } from "../codes/error-codes.js";
import { type WireError, wireErrorSchema } from "./wire.schemas.js";
/**
* Reconstruct an error-shaped object from a wire payload. Zod-validated.
* Malformed input rejects with `ErrorPackageError("ERROR.WIRE.MALFORMED")`.
*/
export function fromJSON(value: unknown): ReconstructedError {
const parsed = wireErrorSchema.safeParse(value);
if (!parsed.success) {
throw new ErrorPackageError(
ERROR_CODES["WIRE.MALFORMED"],
"error.error.wire.malformed",
{ cause: parsed.error }
);
}
return reconstruct(parsed.data);
}
export interface ReconstructedError {
readonly __suaveplanError: 1;
readonly code: string;
readonly message: string;
readonly context?: Readonly<Record<string, unknown>>;
readonly timestamp: number;
readonly traceId?: string;
readonly stack?: string;
readonly name: string;
readonly cause?: ReconstructedError;
}
function reconstruct(wire: WireError): ReconstructedError {
const out: {
__suaveplanError: 1;
code: string;
message: string;
context?: Readonly<Record<string, unknown>>;
timestamp: number;
traceId?: string;
stack?: string;
name: string;
cause?: ReconstructedError;
} = {
__suaveplanError: 1,
code: wire.code,
message: wire.message,
timestamp: wire.timestamp,
name: wire.name,
};
if (wire.context !== undefined) out.context = wire.context;
if (wire.traceId !== undefined) out.traceId = wire.traceId;
if (wire.stack !== undefined) out.stack = wire.stack;
if (wire.cause !== undefined) out.cause = reconstruct(wire.cause);
return out;
}

View File

@@ -0,0 +1,5 @@
export { fromBinary } from "./from-binary.js";
export { fromJSON, type ReconstructedError } from "./from-json.js";
export { toBinary } from "./to-binary.js";
export { type ToJsonOptions, toJSON } from "./to-json.js";
export type { WireError } from "./wire.schemas.js";

View File

@@ -0,0 +1,198 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { ErrorPackageError } from "../base/package-error.js";
import { SuaveplanError } from "../base/suaveplan-error.js";
import { ERROR_CODES } from "../codes/error-codes.js";
import { markSensitive } from "../redact/sensitive.js";
import { fromBinary } from "./from-binary.js";
import { fromJSON } from "./from-json.js";
import { toBinary } from "./to-binary.js";
import { toJSON } from "./to-json.js";
class DemoError extends SuaveplanError<"DEMO.A" | "DEMO.B"> {}
describe("toJSON / fromJSON", () => {
it("preserves code, message, timestamp, name, traceId, context", {
timeout: 5000,
}, () => {
const err = new DemoError("DEMO.A", "demo.a", {
clock: () => 100,
context: { user: "x" },
traceId: "t-1",
});
const wire = toJSON(err);
expect(wire.__suaveplanError).toBe(1);
expect(wire.code).toBe("DEMO.A");
expect(wire.message).toBe("demo.a");
expect(wire.timestamp).toBe(100);
expect(wire.traceId).toBe("t-1");
expect(wire.name).toBe("DemoError");
expect(wire.context).toEqual({ user: "x" });
});
it("includes stack when present", { timeout: 5000 }, () => {
const err = new DemoError("DEMO.A", "k");
const wire = toJSON(err);
expect(typeof wire.stack).toBe("string");
});
it("redacts sensitive context fields on the wire", {
timeout: 5000,
}, () => {
const err = new DemoError("DEMO.A", "k", {
context: { token: markSensitive("xyz"), user: "u" },
});
const wire = toJSON(err);
expect(wire.context).toEqual({ token: "[REDACTED]", user: "u" });
});
it("serializes the cause chain", { timeout: 5000 }, () => {
const root = new DemoError("DEMO.B", "root.key");
const wrapped = new DemoError("DEMO.A", "wrap.key", { cause: root });
const wire = toJSON(wrapped);
expect(wire.cause?.code).toBe("DEMO.B");
});
it("truncates an over-deep chain", { timeout: 5000 }, () => {
const a: { cause?: unknown } = {};
const b: { cause?: unknown } = {};
a.cause = b;
b.cause = a;
const wire = toJSON(a, { maxDepth: 2 });
expect(wire.cause?.cause?.code).toBe("ERROR.CAUSE.TRUNCATED");
});
it("emits fallback fields for non-error cause values", {
timeout: 5000,
}, () => {
const wire = toJSON("just-a-string");
expect(wire.code).toBe("ERROR.CAUSE.TRUNCATED");
});
it("emits defaults when fields are wrong types", {
timeout: 5000,
}, () => {
const wire = toJSON({ code: 123, message: null, timestamp: "x" });
expect(wire.code).toBe("UNKNOWN");
expect(wire.message).toBe("");
expect(wire.timestamp).toBe(0);
expect(wire.name).toBe("Error");
});
it("fromJSON reconstructs a matching wire shape", {
timeout: 5000,
}, () => {
const err = new DemoError("DEMO.A", "k", { clock: () => 7 });
const back = fromJSON(toJSON(err));
expect(back.code).toBe("DEMO.A");
expect(back.timestamp).toBe(7);
expect(back.name).toBe("DemoError");
});
it("fromJSON preserves cause, context, traceId, stack", {
timeout: 5000,
}, () => {
const cause = new DemoError("DEMO.B", "c", { clock: () => 1 });
const err = new DemoError("DEMO.A", "p", {
clock: () => 2,
cause,
context: { a: 1 },
traceId: "t",
});
const back = fromJSON(toJSON(err));
expect(back.cause?.code).toBe("DEMO.B");
expect(back.context).toEqual({ a: 1 });
expect(back.traceId).toBe("t");
expect(typeof back.stack).toBe("string");
});
it("fromJSON handles wire payloads without optional fields", {
timeout: 5000,
}, () => {
const back = fromJSON({
__suaveplanError: 1,
code: "X.Y",
message: "m",
timestamp: 0,
name: "X",
});
expect(back.context).toBeUndefined();
expect(back.traceId).toBeUndefined();
expect(back.stack).toBeUndefined();
expect(back.cause).toBeUndefined();
});
it("fromJSON rejects malformed input with ErrorPackageError", {
timeout: 5000,
}, () => {
expect(() => fromJSON({ code: 123 })).toThrow(ErrorPackageError);
try {
fromJSON({ code: 123 });
} catch (err) {
expect((err as ErrorPackageError).code).toBe(
ERROR_CODES["WIRE.MALFORMED"]
);
}
});
});
describe("toBinary / fromBinary", () => {
it("round-trips equivalently to the JSON form", { timeout: 5000 }, () => {
const err = new DemoError("DEMO.A", "k", { clock: () => 5 });
const wire = toJSON(err);
const back = fromBinary(toBinary(err));
expect(back).toEqual(fromJSON(wire));
});
it("rejects non-UTF8 input with BINARY.TRUNCATED", {
timeout: 5000,
}, () => {
// Lone continuation byte — invalid UTF-8.
expect(() => fromBinary(new Uint8Array([0xff]))).toThrow(
ErrorPackageError
);
});
it("rejects invalid JSON bytes with BINARY.TRUNCATED", {
timeout: 5000,
}, () => {
const bytes = new TextEncoder().encode("{not-json");
try {
fromBinary(bytes);
expect.unreachable();
} catch (err) {
expect((err as ErrorPackageError).code).toBe(
ERROR_CODES["BINARY.TRUNCATED"]
);
}
});
});
describe("toJSON — property-based", () => {
it("round-trips arbitrary code+message+traceId errors", {
timeout: 10000,
}, () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 32 }),
fc.string({ maxLength: 128 }),
fc.option(fc.string({ maxLength: 32 }), { nil: undefined }),
fc.integer({ min: 0, max: 2 ** 40 }),
(code, msg, traceId, ts) => {
const err = new DemoError("DEMO.A", msg, {
clock: () => ts,
...(traceId !== undefined ? { traceId } : {}),
});
const back = fromJSON(toJSON(err));
return (
back.code === "DEMO.A" &&
back.message === msg &&
back.timestamp === ts &&
back.traceId === traceId
);
}
),
{ numRuns: 40 }
);
});
});

View File

@@ -0,0 +1,53 @@
# to-binary
## Purpose
`toBinary` encodes any error-shaped value into a compact byte payload suitable for binary transports (message queues, WebSocket frames, Redis streams). It reuses `toJSON` for canonicalisation and then UTF-8-encodes the result, keeping the implementation small enough to audit in one sitting while still producing a lossless, Zod-checked wire form on the receiving side.
## Features
- Zero external encoder dependency — uses `TextEncoder` (available in every supported runtime)
- Deterministic — same input always yields the same bytes (JSON canonicalises field order via the wire builder)
- Round-trips losslessly via `fromBinary`
- Same `maxDepth` / redaction / cycle-safety guarantees as `toJSON`
- Output is a `Uint8Array` — works with Node's `Buffer` (which `extends Uint8Array`), browser `fetch`, and WebSocket frames
## Usage: Basic Example
```ts
import { toBinary, ErrorPackageError, ERROR_CODES } from "@suaveplan/error";
const bytes = toBinary(
new ErrorPackageError(ERROR_CODES["WIRE.MALFORMED"], "error.error.wire.malformed")
);
bytes.byteLength; // >0
```
## Usage: Advanced Example
```ts
import { toBinary, fromBinary } from "@suaveplan/error";
async function publishFailure(err: unknown, ws: WebSocket): Promise<void> {
const frame = toBinary(err, { maxDepth: 8 });
ws.send(frame);
}
async function consumeFailure(event: MessageEvent<ArrayBuffer>): Promise<void> {
const bytes = new Uint8Array(event.data);
const decoded = fromBinary(bytes);
console.error(decoded.code, decoded.traceId);
}
```
## API Reference
### `toBinary(err: unknown, options?: ToJsonOptions): Uint8Array`
- `err` — any value; same acceptance rules as `toJSON`
- `options.maxDepth` — forwarded to `toJSON`; defaults to `MAX_CAUSE_DEPTH`
- Returns UTF-8 bytes of the canonicalised JSON wire form
## Implementation Notes
The binary form is intentionally UTF-8 JSON rather than MessagePack or Protobuf — audit surface stays in one function, and the runtime cost (one `JSON.stringify` + one `TextEncoder.encode`) is small compared to the transport overhead. If a consumer later wants a more compact format, they can replace this module without touching any public wire contract because `fromBinary` only decodes what `toBinary` encodes. The Zod validation happens on the `fromBinary` side via `fromJSON`, so a corrupted wire always surfaces as `ERROR.WIRE.MALFORMED` rather than an opaque parse error.

View File

@@ -0,0 +1,11 @@
import { type ToJsonOptions, toJSON } from "./to-json.js";
/**
* Encode an error to a compact binary payload (UTF-8 bytes of the canonical
* JSON form). Round-trips losslessly via `fromBinary`.
*/
export function toBinary(err: unknown, options?: ToJsonOptions): Uint8Array {
const wire = toJSON(err, options);
const json = JSON.stringify(wire);
return new TextEncoder().encode(json);
}

View File

@@ -0,0 +1,74 @@
# to-json
## Purpose
`toJSON` serialises any error-shaped value to a wire-safe plain object that round-trips losslessly back through `fromJSON`. It walks the `cause` chain, applies redaction at the boundary, truncates at a configurable depth to prevent unbounded payloads, and emits the current wire version so future decoders can reject or upgrade old payloads deterministically.
## Features
- Accepts any value — `SuaveplanError`, plain `Error`, bare object, primitives (primitives produce a truncated stub)
- Walks and encodes the full `cause` chain, one envelope per link
- Replaces `Sensitive<T>`-tagged context fields with `"[REDACTED]"`
- Cycle-safe via `WeakSet`
- Depth-bounded (default `MAX_CAUSE_DEPTH` = 32; override per call)
- Emits `__suaveplanError: 1` version marker for forward-compatible decoding
## Usage: Basic Example
```ts
import { toJSON, ErrorPackageError, ERROR_CODES } from "@suaveplan/error";
const err = new ErrorPackageError(
ERROR_CODES["WIRE.MALFORMED"],
"error.error.wire.malformed"
);
JSON.stringify(toJSON(err));
```
## Usage: Advanced Example
```ts
import { SuaveplanError, defineCodes, markSensitive, toJSON } from "@suaveplan/error";
const CODES = defineCodes("ORDER", ["PAYMENT.DECLINED"] as const);
class OrderError extends SuaveplanError<typeof CODES[keyof typeof CODES]> {
public constructor(key: string, options?: ConstructorParameters<typeof SuaveplanError>[2]) {
super(CODES["PAYMENT.DECLINED"], key, options);
}
}
const inner = new Error("stripe 402");
const err = new OrderError("error.order.payment.declined", {
cause: inner,
context: {
orderId: "o-42",
customerToken: markSensitive("tok_live_xyz"),
},
traceId: "trace-9",
});
const wire = toJSON(err, { maxDepth: 4 });
// wire.code === "ORDER.PAYMENT.DECLINED"
// wire.context.customerToken === "[REDACTED]"
// wire.cause?.message === "stripe 402"
// wire.__suaveplanError === 1
```
## API Reference
### `toJSON(err: unknown, options?: ToJsonOptions): WireError`
- `err` — any value; non-object primitives produce a truncated stub with `code: "ERROR.CAUSE.TRUNCATED"`
- `options.maxDepth` — maximum cause-chain depth; defaults to `MAX_CAUSE_DEPTH` (32)
### `interface ToJsonOptions`
- `readonly maxDepth?: number`
### `interface WireError`
See `@suaveplan/error/wire.schemas` — shape includes `code`, `message`, `context?`, `timestamp`, `traceId?`, `stack?`, `name`, `cause?`, and the `__suaveplanError: 1` version literal.
## Implementation Notes
Fields with the wrong runtime type (e.g. a numeric `code` or a string `timestamp`) collapse to defaults (`"UNKNOWN"`, `0`, `""`, `"Error"`) rather than throwing, so an ad-hoc `throw { code: 123 }` still produces a valid envelope. Cycles emit a single `TruncatedCause` sentinel rather than infinite recursion — the same sentinel is used when `maxDepth` is exhausted, keeping the consumer-visible wire shape uniform. Redaction is applied one level deep on `context`; nested sensitive values inside arrays or deeper objects are NOT transitively scanned by this package — callers with deeper redaction needs should structure their context accordingly.

View File

@@ -0,0 +1,112 @@
import { SuaveplanError } from "../base/suaveplan-error.js";
import {
MAX_CAUSE_DEPTH,
REDACTED_LABEL,
WIRE_VERSION,
} from "../constants/index.js";
import { isSensitive } from "../redact/sensitive.js";
import type { WireError } from "./wire.schemas.js";
export interface ToJsonOptions {
readonly maxDepth?: number;
}
/**
* Serialize a `SuaveplanError` (or any cause-bearing value) to a wire-safe
* plain object. Redacted context fields become the literal `"[REDACTED]"`.
* Cycles and over-deep chains are truncated at `maxDepth`.
*/
export function toJSON(err: unknown, options: ToJsonOptions = {}): WireError {
const maxDepth = options.maxDepth ?? MAX_CAUSE_DEPTH;
return encode(err, maxDepth, new WeakSet<object>());
}
function encode(
err: unknown,
remaining: number,
seen: WeakSet<object>
): WireError {
if (remaining <= 0 || !isObject(err) || seen.has(err)) {
return {
__suaveplanError: WIRE_VERSION,
code: "ERROR.CAUSE.TRUNCATED",
message: "[cause chain truncated]",
timestamp: 0,
name: "TruncatedCause",
};
}
seen.add(err);
const src = err as {
code?: unknown;
message?: unknown;
context?: unknown;
timestamp?: unknown;
traceId?: unknown;
stack?: unknown;
name?: unknown;
cause?: unknown;
};
const wire: {
__suaveplanError: 1;
code: string;
message: string;
context?: Readonly<Record<string, unknown>>;
timestamp: number;
traceId?: string;
stack?: string;
name: string;
cause?: WireError;
} = {
__suaveplanError: WIRE_VERSION,
code: typeof src.code === "string" ? src.code : "UNKNOWN",
message: typeof src.message === "string" ? src.message : "",
timestamp: typeof src.timestamp === "number" ? src.timestamp : 0,
name: typeof src.name === "string" ? src.name : "Error",
};
if (isObject(src.context)) {
wire.context = redactContext(src.context, new WeakSet<object>());
}
if (typeof src.traceId === "string") {
wire.traceId = src.traceId;
}
if (typeof src.stack === "string") {
wire.stack = src.stack;
}
if (src.cause !== undefined) {
wire.cause = encode(src.cause, remaining - 1, seen);
}
return wire;
}
function redactContext(
ctx: object,
seen: WeakSet<object>
): Readonly<Record<string, unknown>> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(ctx)) {
out[key] = redactValue(value, seen);
}
return out;
}
function redactValue(value: unknown, seen: WeakSet<object>): unknown {
if (isSensitive(value)) return REDACTED_LABEL;
if (!isObject(value)) return value;
if (seen.has(value)) return value;
seen.add(value);
if (Array.isArray(value)) {
return value.map((item) => redactValue(item, seen));
}
return redactContext(value, seen);
}
function isObject(value: unknown): value is object {
return typeof value === "object" && value !== null;
}
// Re-export for convenience (some consumers want to narrow before serializing).
export { SuaveplanError };

View File

@@ -0,0 +1,30 @@
import z from "zod";
// biome-ignore lint/suspicious/noExplicitAny: required for z.lazy self-reference
const wireErrorSchemaImpl: z.ZodType<any> = z.lazy(() =>
z.object({
__suaveplanError: z.literal(1),
code: z.string(),
message: z.string(),
context: z.record(z.string(), z.unknown()).optional(),
timestamp: z.number(),
traceId: z.string().optional(),
stack: z.string().optional(),
name: z.string(),
cause: wireErrorSchemaImpl.optional(),
})
);
export interface WireError {
readonly __suaveplanError: 1;
readonly code: string;
readonly message: string;
readonly context?: Readonly<Record<string, unknown>>;
readonly timestamp: number;
readonly traceId?: string;
readonly stack?: string;
readonly name: string;
readonly cause?: WireError;
}
export const wireErrorSchema = wireErrorSchemaImpl as z.ZodType<WireError>;

View File

@@ -0,0 +1,34 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("@suaveplan/types is the only @suaveplan/* runtime dep; no peer deps", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
const runtimeScoped = Object.keys(pkg.dependencies ?? {}).filter(
(name) => name.startsWith("@suaveplan/")
);
expect(runtimeScoped).toEqual(["@suaveplan/types"]);
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
(name) => name.startsWith("@suaveplan/")
);
expect(peerScoped).toEqual([]);
});
it("runtime deps are limited to @suaveplan/types + tslib + zod", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual(["@suaveplan/types", "tslib", "zod"]);
});
});

View File

@@ -0,0 +1,41 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC = join(__dirname, "..", "src");
const BANNED: ReadonlyArray<RegExp> = [
/\bnode:fs\b/,
/\bnode:path\b/,
/\bnode:url\b/,
/\bprocess\./,
/\bBuffer\b/,
/\bdocument\b/,
/\bwindow\b/,
];
function* walk(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) yield* walk(full);
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
}
}
describe("no-disallowed", () => {
it("src/ does not reference Node or browser built-ins", {
timeout: 5000,
}, () => {
const hits: string[] = [];
for (const file of walk(SRC)) {
const content = readFileSync(file, "utf8");
for (const pattern of BANNED) {
if (pattern.test(content)) {
hits.push(`${file} matched ${pattern}`);
}
}
}
expect(hits).toEqual([]);
});
});

View File

@@ -0,0 +1,3 @@
// No polyfills required — `@suaveplan/error` runs on bare Node/Bun/browser.
// File exists so vitest's `setupFiles` has a stable target across packages.
export {};

View File

@@ -0,0 +1,21 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("tree-shake", () => {
it("sideEffects is false", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { sideEffects?: boolean };
expect(pkg.sideEffects).toBe(false);
});
it("exports are per-module entry points", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { exports?: Record<string, unknown> };
expect(pkg.exports).toBeDefined();
// Root + several subpaths (sanity: at least 5).
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(5);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/error",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: "src/index.ts",
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: ["@suaveplan/types", "tslib", "zod", /^node:/],
},
},
});

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});