first commit
This commit is contained in:
113
docs/foundation/core/error/README.md
Normal file
113
docs/foundation/core/error/README.md
Normal 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
|
||||
```
|
||||
|
||||
57
docs/foundation/core/error/src/base/package-error.md
Normal file
57
docs/foundation/core/error/src/base/package-error.md
Normal 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.
|
||||
83
docs/foundation/core/error/src/base/suaveplan-error.md
Normal file
83
docs/foundation/core/error/src/base/suaveplan-error.md
Normal 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.
|
||||
72
docs/foundation/core/error/src/causes/walk-causes.md
Normal file
72
docs/foundation/core/error/src/causes/walk-causes.md
Normal 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.
|
||||
66
docs/foundation/core/error/src/codes/define-codes.md
Normal file
66
docs/foundation/core/error/src/codes/define-codes.md
Normal 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.
|
||||
70
docs/foundation/core/error/src/codes/error-codes.md
Normal file
70
docs/foundation/core/error/src/codes/error-codes.md
Normal 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.
|
||||
83
docs/foundation/core/error/src/i18n/resolver.md
Normal file
83
docs/foundation/core/error/src/i18n/resolver.md
Normal 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.
|
||||
78
docs/foundation/core/error/src/otel/to-span-event.md
Normal file
78
docs/foundation/core/error/src/otel/to-span-event.md
Normal 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.
|
||||
76
docs/foundation/core/error/src/redact/sensitive.md
Normal file
76
docs/foundation/core/error/src/redact/sensitive.md
Normal 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.
|
||||
58
docs/foundation/core/error/src/serialize/from-binary.md
Normal file
58
docs/foundation/core/error/src/serialize/from-binary.md
Normal 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.
|
||||
70
docs/foundation/core/error/src/serialize/from-json.md
Normal file
70
docs/foundation/core/error/src/serialize/from-json.md
Normal 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.
|
||||
53
docs/foundation/core/error/src/serialize/to-binary.md
Normal file
53
docs/foundation/core/error/src/serialize/to-binary.md
Normal 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.
|
||||
74
docs/foundation/core/error/src/serialize/to-json.md
Normal file
74
docs/foundation/core/error/src/serialize/to-json.md
Normal 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.
|
||||
Reference in New Issue
Block a user