first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 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,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,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,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,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,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,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,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,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,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,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,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,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.