Initial Commit
This commit is contained in:
113
packages/foundation/core/error/README.md
Normal file
113
packages/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
|
||||
```
|
||||
|
||||
95
packages/foundation/core/error/package.json
Normal file
95
packages/foundation/core/error/package.json
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"name": "@suaveplan/error",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Foundation error substrate — SuaveplanError base class, stable code registry, cause-chain traversal, Zod-validated wire format (JSON + MessagePack), redaction, OTel span-event adapter, i18n resolver hook. Zero @suaveplan/* deps.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./base": {
|
||||
"types": "./dist/base/suaveplan-error.d.ts",
|
||||
"default": "./dist/base/suaveplan-error.js"
|
||||
},
|
||||
"./codes": {
|
||||
"types": "./dist/codes/define-codes.d.ts",
|
||||
"default": "./dist/codes/define-codes.js"
|
||||
},
|
||||
"./causes": {
|
||||
"types": "./dist/causes/walk-causes.d.ts",
|
||||
"default": "./dist/causes/walk-causes.js"
|
||||
},
|
||||
"./serialize": {
|
||||
"types": "./dist/serialize/index.d.ts",
|
||||
"default": "./dist/serialize/index.js"
|
||||
},
|
||||
"./redact": {
|
||||
"types": "./dist/redact/sensitive.d.ts",
|
||||
"default": "./dist/redact/sensitive.js"
|
||||
},
|
||||
"./otel": {
|
||||
"types": "./dist/otel/to-span-event.d.ts",
|
||||
"default": "./dist/otel/to-span-event.js"
|
||||
},
|
||||
"./i18n": {
|
||||
"types": "./dist/i18n/resolver.d.ts",
|
||||
"default": "./dist/i18n/resolver.js"
|
||||
},
|
||||
"./wire.schemas": {
|
||||
"types": "./dist/serialize/wire.schemas.d.ts",
|
||||
"default": "./dist/serialize/wire.schemas.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"bench": "bun run src/__benchmarks__/serialize.bench.ts",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"mitata": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
6
packages/foundation/core/error/src/base/index.ts
Normal file
6
packages/foundation/core/error/src/base/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { ErrorPackageError } from "./package-error";
|
||||
export { defaultClock, SuaveplanError } from "./suaveplan-error";
|
||||
export type {
|
||||
ErrorContext,
|
||||
SuaveplanErrorOptions,
|
||||
} from "./suaveplan-error.types";
|
||||
57
packages/foundation/core/error/src/base/package-error.md
Normal file
57
packages/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.
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ERROR_CODES } from "../codes/error-codes.js";
|
||||
import { ErrorPackageError } from "./package-error.js";
|
||||
import { SuaveplanError } from "./suaveplan-error.js";
|
||||
|
||||
describe("ErrorPackageError", () => {
|
||||
it("extends SuaveplanError and accepts a package code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new ErrorPackageError(
|
||||
ERROR_CODES["WIRE.MALFORMED"],
|
||||
"error.error.wire.malformed"
|
||||
);
|
||||
expect(err).toBeInstanceOf(SuaveplanError);
|
||||
expect(err.code).toBe("ERROR.WIRE.MALFORMED");
|
||||
});
|
||||
});
|
||||
18
packages/foundation/core/error/src/base/package-error.ts
Normal file
18
packages/foundation/core/error/src/base/package-error.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ErrorCode } from "../codes/error-codes.js";
|
||||
import { SuaveplanError } from "./suaveplan-error.js";
|
||||
import type { SuaveplanErrorOptions } from "./suaveplan-error.types.js";
|
||||
|
||||
/**
|
||||
* Concrete subclass shipped by `@suaveplan/error` for its own internal
|
||||
* failure modes (wire validation, binary bounds, cycle detection).
|
||||
*/
|
||||
export class ErrorPackageError extends SuaveplanError<ErrorCode> {
|
||||
// biome-ignore lint/complexity/noUselessConstructor: widens the protected base constructor to public
|
||||
public constructor(
|
||||
code: ErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
}
|
||||
}
|
||||
83
packages/foundation/core/error/src/base/suaveplan-error.md
Normal file
83
packages/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.
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Spec-conformance tests for `SuaveplanError`. Covers the two scenarios
|
||||
* in `openspec/specs/error/spec.md` that the original test suite did not
|
||||
* pin explicitly: subclass code-union narrowing and subclass-name in
|
||||
* `err.stack`.
|
||||
*/
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import { SuaveplanError } from "./suaveplan-error.js";
|
||||
|
||||
class NarrowError extends SuaveplanError<"NARROW.X.Y" | "NARROW.X.Z"> {
|
||||
constructor(code: "NARROW.X.Y" | "NARROW.X.Z", key = "k") {
|
||||
super(code, key);
|
||||
}
|
||||
}
|
||||
|
||||
describe("SuaveplanError — code-union narrowing", () => {
|
||||
it("subclass narrows err.code to its declared literal union", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err: unknown = new NarrowError("NARROW.X.Y");
|
||||
expect(err).toBeInstanceOf(NarrowError);
|
||||
if (err instanceof NarrowError) {
|
||||
// Compile-time assertion: code is the literal union, not `string`.
|
||||
expectTypeOf(err.code).toEqualTypeOf<"NARROW.X.Y" | "NARROW.X.Z">();
|
||||
// Runtime check, just to keep istanbul satisfied.
|
||||
expect(err.code).toBe("NARROW.X.Y");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SuaveplanError — stack trace preservation", () => {
|
||||
it("err.stack contains the subclass name, not just 'Error'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new NarrowError("NARROW.X.Z");
|
||||
const stack = err.stack ?? "";
|
||||
expect(stack).toContain("NarrowError");
|
||||
// The first non-empty line should reference the subclass, not
|
||||
// the parent `Error`.
|
||||
const firstLine = stack.split("\n").find((l) => l.trim().length > 0);
|
||||
expect(firstLine).toBeDefined();
|
||||
expect(firstLine).toMatch(/NarrowError/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { clearMessageResolver, setMessageResolver } from "../i18n/resolver.js";
|
||||
import { SuaveplanError } from "./suaveplan-error.js";
|
||||
|
||||
class TestError extends SuaveplanError<"TEST.A" | "TEST.B"> {}
|
||||
|
||||
afterEach(() => {
|
||||
clearMessageResolver();
|
||||
});
|
||||
|
||||
describe("SuaveplanError — construction", () => {
|
||||
it("sets code, name, default timestamp, message", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new TestError("TEST.A", "m");
|
||||
expect(err.code).toBe("TEST.A");
|
||||
expect(err.name).toBe("TestError");
|
||||
expect(err.message).toBe("m");
|
||||
expect(typeof err.timestamp).toBe("number");
|
||||
expect(err instanceof SuaveplanError).toBe(true);
|
||||
});
|
||||
|
||||
it("uses injected clock for timestamp", { timeout: 5000 }, () => {
|
||||
const err = new TestError("TEST.A", "m", { clock: () => 42 });
|
||||
expect(err.timestamp).toBe(42);
|
||||
});
|
||||
|
||||
it("carries context, cause, traceId", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root");
|
||||
const err = new TestError("TEST.B", "m", {
|
||||
context: { user: "x" },
|
||||
cause,
|
||||
traceId: "t-1",
|
||||
});
|
||||
expect(err.context).toEqual({ user: "x" });
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.traceId).toBe("t-1");
|
||||
});
|
||||
|
||||
it("omits context / traceId when not provided", { timeout: 5000 }, () => {
|
||||
const err = new TestError("TEST.A", "m");
|
||||
expect(err.context).toBeUndefined();
|
||||
expect(err.traceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves message key through the installed resolver", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
setMessageResolver((key, params) => `${key}!${params?.["x"] ?? ""}`);
|
||||
const err = new TestError("TEST.A", "k", { context: { x: 1 } });
|
||||
expect(err.message).toBe("k!1");
|
||||
});
|
||||
|
||||
it("captureStackTrace fallback when host lacks Error.captureStackTrace", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = (Error as { captureStackTrace?: unknown })
|
||||
.captureStackTrace;
|
||||
(Error as { captureStackTrace?: unknown }).captureStackTrace =
|
||||
undefined;
|
||||
try {
|
||||
const err = new TestError("TEST.A", "m");
|
||||
expect(err.code).toBe("TEST.A");
|
||||
} finally {
|
||||
(Error as { captureStackTrace?: unknown }).captureStackTrace =
|
||||
original;
|
||||
}
|
||||
});
|
||||
});
|
||||
57
packages/foundation/core/error/src/base/suaveplan-error.ts
Normal file
57
packages/foundation/core/error/src/base/suaveplan-error.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { resolveMessage } from "../i18n/resolver.js";
|
||||
import type {
|
||||
ErrorContext,
|
||||
SuaveplanErrorOptions,
|
||||
} from "./suaveplan-error.types.js";
|
||||
|
||||
/**
|
||||
* Foundation error class for the entire Suaveplan monorepo.
|
||||
*
|
||||
* Every non-testing package SHOULD extend this class with a literal-typed
|
||||
* `TCode` parameter so `instanceof` narrows the code union.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class AuthError extends SuaveplanError<"AUTH.LOGIN.INVALID" | "AUTH.LOGIN.LOCKED"> {}
|
||||
* throw new AuthError("AUTH.LOGIN.INVALID", "error.auth.login.invalid");
|
||||
* ```
|
||||
*/
|
||||
export abstract class SuaveplanError<
|
||||
TCode extends string = string,
|
||||
> extends Error {
|
||||
public readonly code: TCode;
|
||||
public readonly context?: ErrorContext;
|
||||
public readonly timestamp: number;
|
||||
public readonly traceId?: string;
|
||||
|
||||
protected constructor(
|
||||
code: TCode,
|
||||
messageKey: string,
|
||||
options: SuaveplanErrorOptions = {}
|
||||
) {
|
||||
super(resolveMessage(messageKey, options.context), {
|
||||
cause: options.cause,
|
||||
});
|
||||
this.name = new.target.name;
|
||||
this.code = code;
|
||||
this.timestamp = (options.clock ?? defaultClock)();
|
||||
if (options.context !== undefined) {
|
||||
this.context = options.context;
|
||||
}
|
||||
if (options.traceId !== undefined) {
|
||||
this.traceId = options.traceId;
|
||||
}
|
||||
const capture = (Error as ErrorWithCaptureStackTrace).captureStackTrace;
|
||||
if (typeof capture === "function") {
|
||||
capture(this, new.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ErrorWithCaptureStackTrace {
|
||||
// biome-ignore lint/complexity/noBannedTypes: Node's Error.captureStackTrace signature takes a Function
|
||||
captureStackTrace?: (target: object, constructorOpt: Function) => void;
|
||||
}
|
||||
|
||||
/** Lives in one place so tests and consumers can swap it via property mock. */
|
||||
export const defaultClock = (): number => Date.now();
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Context map attached to a SuaveplanError. Values may be `Sensitive<T>`. */
|
||||
export type ErrorContext = Readonly<Record<string, unknown>>;
|
||||
|
||||
/** Optional construction options for any `SuaveplanError` subclass. */
|
||||
export interface SuaveplanErrorOptions {
|
||||
/** Structured fields attached to the error; redacted at the wire boundary. */
|
||||
readonly context?: ErrorContext;
|
||||
/** Underlying cause — preserved for `walkCauses`, serialised in-chain. */
|
||||
readonly cause?: unknown;
|
||||
/** Trace correlation id, propagated to `toSpanEvent`. */
|
||||
readonly traceId?: string;
|
||||
/**
|
||||
* Time source. Defaults to `() => Date.now()`. Inject for determinism.
|
||||
* Never read at module-import time.
|
||||
*/
|
||||
readonly clock?: () => number;
|
||||
}
|
||||
1
packages/foundation/core/error/src/causes/index.ts
Normal file
1
packages/foundation/core/error/src/causes/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { type WalkCausesOptions, walkCauses } from "./walk-causes";
|
||||
72
packages/foundation/core/error/src/causes/walk-causes.md
Normal file
72
packages/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.
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { walkCauses } from "./walk-causes.js";
|
||||
|
||||
describe("walkCauses", () => {
|
||||
it("yields a linear chain to the leaf", { timeout: 5000 }, () => {
|
||||
const d: { cause?: unknown } = {};
|
||||
const c = { cause: d };
|
||||
const b = { cause: c };
|
||||
const a = { cause: b };
|
||||
expect(Array.from(walkCauses(a))).toEqual([a, b, c, d]);
|
||||
});
|
||||
|
||||
it("terminates on self-cycle", { timeout: 5000 }, () => {
|
||||
const a: { cause?: unknown } = {};
|
||||
a.cause = a;
|
||||
const out = Array.from(walkCauses(a));
|
||||
expect(out).toEqual([a]);
|
||||
});
|
||||
|
||||
it("terminates on mutual cycle", { timeout: 5000 }, () => {
|
||||
const a: { cause?: unknown } = {};
|
||||
const b: { cause?: unknown } = { cause: a };
|
||||
a.cause = b;
|
||||
expect(Array.from(walkCauses(a))).toEqual([a, b]);
|
||||
});
|
||||
|
||||
it("enforces depth cap", { timeout: 5000 }, () => {
|
||||
let node: { cause?: unknown } = {};
|
||||
const root = node;
|
||||
for (let i = 0; i < 99; i++) {
|
||||
const next: { cause?: unknown } = {};
|
||||
node.cause = next;
|
||||
node = next;
|
||||
}
|
||||
const out = Array.from(walkCauses(root, { maxDepth: 10 }));
|
||||
expect(out.length).toBe(10);
|
||||
});
|
||||
|
||||
it("short-circuits on zero / negative depth", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Array.from(walkCauses({}, { maxDepth: 0 }))).toEqual([]);
|
||||
expect(Array.from(walkCauses({}, { maxDepth: -1 }))).toEqual([]);
|
||||
});
|
||||
|
||||
it("terminates at non-object cause", { timeout: 5000 }, () => {
|
||||
const a = { cause: "stringy" };
|
||||
expect(Array.from(walkCauses(a))).toEqual([a, "stringy"]);
|
||||
});
|
||||
|
||||
it("terminates at null cause", { timeout: 5000 }, () => {
|
||||
const a = { cause: null };
|
||||
expect(Array.from(walkCauses(a))).toEqual([a, null]);
|
||||
});
|
||||
|
||||
it("terminates when cause is undefined", { timeout: 5000 }, () => {
|
||||
expect(Array.from(walkCauses({}))).toEqual([{}]);
|
||||
});
|
||||
|
||||
it("handles a primitive root", { timeout: 5000 }, () => {
|
||||
expect(Array.from(walkCauses("boom"))).toEqual(["boom"]);
|
||||
});
|
||||
});
|
||||
37
packages/foundation/core/error/src/causes/walk-causes.ts
Normal file
37
packages/foundation/core/error/src/causes/walk-causes.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { MAX_CAUSE_DEPTH } from "../constants/index.js";
|
||||
|
||||
export interface WalkCausesOptions {
|
||||
/** Maximum depth (inclusive of the root). Default: `MAX_CAUSE_DEPTH` (32). */
|
||||
readonly maxDepth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield each error in the cause chain, starting from `root`.
|
||||
* Cycle-safe (tracks visited objects via WeakSet) and depth-bounded.
|
||||
* Non-object causes (strings, numbers, undefined) terminate the walk.
|
||||
*/
|
||||
export function* walkCauses(
|
||||
root: unknown,
|
||||
options: WalkCausesOptions = {}
|
||||
): Generator<unknown, void, void> {
|
||||
const maxDepth = options.maxDepth ?? MAX_CAUSE_DEPTH;
|
||||
if (maxDepth <= 0) return;
|
||||
|
||||
const seen = new WeakSet<object>();
|
||||
let current: unknown = root;
|
||||
let depth = 0;
|
||||
|
||||
while (depth < maxDepth) {
|
||||
if (typeof current === "object" && current !== null) {
|
||||
if (seen.has(current)) return;
|
||||
seen.add(current);
|
||||
}
|
||||
yield current;
|
||||
depth++;
|
||||
|
||||
if (typeof current !== "object" || current === null) return;
|
||||
const next = (current as { cause?: unknown }).cause;
|
||||
if (next === undefined) return;
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
66
packages/foundation/core/error/src/codes/define-codes.md
Normal file
66
packages/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.
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Spec-conformance tests for `defineCodes`. Pins the type-level scenario
|
||||
* "Code values are literal-typed" from `openspec/specs/error/spec.md`.
|
||||
*/
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import { defineCodes } from "./define-codes.js";
|
||||
|
||||
describe("defineCodes — literal-typed values", () => {
|
||||
it("code values are literal-typed (e.g. `PKG.A`, not `string`)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codes = defineCodes("PKG", ["A", "B"] as const);
|
||||
// Compile-time: literal types preserved through registry.
|
||||
expectTypeOf(codes.A).toEqualTypeOf<"PKG.A">();
|
||||
expectTypeOf(codes.B).toEqualTypeOf<"PKG.B">();
|
||||
// Runtime sanity.
|
||||
expect(codes.A).toBe("PKG.A");
|
||||
expect(codes.B).toBe("PKG.B");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { defineCodes } from "./define-codes.js";
|
||||
import { ERROR_CODES } from "./error-codes.js";
|
||||
|
||||
describe("defineCodes", () => {
|
||||
it("prefixes every reason with the namespace", { timeout: 5000 }, () => {
|
||||
const codes = defineCodes("PKG", ["A", "B"] as const);
|
||||
expect(codes.A).toBe("PKG.A");
|
||||
expect(codes.B).toBe("PKG.B");
|
||||
});
|
||||
|
||||
it("freezes the returned record", { timeout: 5000 }, () => {
|
||||
const codes = defineCodes("X", ["Y"] as const);
|
||||
expect(Object.isFrozen(codes)).toBe(true);
|
||||
expect(() => {
|
||||
(codes as { Y: string }).Y = "X.Z";
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("covers nested namespaces like domain.reason", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(ERROR_CODES["WIRE.MALFORMED"]).toBe("ERROR.WIRE.MALFORMED");
|
||||
});
|
||||
});
|
||||
22
packages/foundation/core/error/src/codes/define-codes.ts
Normal file
22
packages/foundation/core/error/src/codes/define-codes.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Build a frozen record mapping each reason to a fully-qualified code string
|
||||
* of the form `<namespace>.<reason>`.
|
||||
*
|
||||
* The return type is fully inferred: `defineCodes("AUTH", ["LOGIN_FAILED"])`
|
||||
* yields `{ readonly LOGIN_FAILED: "AUTH.LOGIN_FAILED" }`.
|
||||
*/
|
||||
export function defineCodes<
|
||||
Namespace extends string,
|
||||
Reasons extends readonly string[],
|
||||
>(
|
||||
namespace: Namespace,
|
||||
reasons: Reasons
|
||||
): { readonly [K in Reasons[number]]: `${Namespace}.${K}` } {
|
||||
const out = Object.create(null) as Record<string, string>;
|
||||
for (const reason of reasons) {
|
||||
out[reason] = `${namespace}.${reason}`;
|
||||
}
|
||||
return Object.freeze(out) as {
|
||||
readonly [K in Reasons[number]]: `${Namespace}.${K}`;
|
||||
};
|
||||
}
|
||||
70
packages/foundation/core/error/src/codes/error-codes.md
Normal file
70
packages/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.
|
||||
13
packages/foundation/core/error/src/codes/error-codes.ts
Normal file
13
packages/foundation/core/error/src/codes/error-codes.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineCodes } from "./define-codes.js";
|
||||
|
||||
/** Package-local error codes for `@suaveplan/error` itself. */
|
||||
export const ERROR_CODES = defineCodes("ERROR", [
|
||||
"WIRE.MALFORMED",
|
||||
"WIRE.UNSUPPORTED_VERSION",
|
||||
"BINARY.OUT_OF_RANGE",
|
||||
"BINARY.TRUNCATED",
|
||||
"BINARY.UNSUPPORTED_TYPE",
|
||||
"CAUSE.DEPTH_EXCEEDED",
|
||||
] as const);
|
||||
|
||||
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||
2
packages/foundation/core/error/src/codes/index.ts
Normal file
2
packages/foundation/core/error/src/codes/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { defineCodes } from "./define-codes";
|
||||
export { ERROR_CODES, type ErrorCode } from "./error-codes";
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ErrorPackageError } from "./base/package-error.js";
|
||||
import { ERROR_CODES } from "./codes/error-codes.js";
|
||||
import { markSensitive, unwrapSensitive } from "./redact/sensitive.js";
|
||||
import { fromJSON } from "./serialize/from-json.js";
|
||||
import { toJSON } from "./serialize/to-json.js";
|
||||
|
||||
const TIMEOUT = { timeout: 5000 } as const;
|
||||
|
||||
function makeError(
|
||||
context?: Readonly<Record<string, unknown>>
|
||||
): ErrorPackageError {
|
||||
return new ErrorPackageError(
|
||||
ERROR_CODES["WIRE.MALFORMED"],
|
||||
"error.test",
|
||||
context !== undefined ? { context } : undefined
|
||||
);
|
||||
}
|
||||
|
||||
describe("error spec conformance", () => {
|
||||
describe("Recursive redaction", () => {
|
||||
it(
|
||||
"redacts sensitive values nested inside sub-objects",
|
||||
TIMEOUT,
|
||||
() => {
|
||||
const err = makeError({
|
||||
user: { profile: { token: markSensitive("abc") } },
|
||||
});
|
||||
const wire = toJSON(err);
|
||||
const ctx = wire.context as {
|
||||
user: { profile: { token: string } };
|
||||
};
|
||||
expect(ctx.user.profile.token).toBe("[REDACTED]");
|
||||
}
|
||||
);
|
||||
|
||||
it("redacts sensitive values inside arrays", TIMEOUT, () => {
|
||||
const err = makeError({
|
||||
tokens: [markSensitive("a"), markSensitive("b"), "plain"],
|
||||
});
|
||||
const wire = toJSON(err);
|
||||
const ctx = wire.context as { tokens: unknown[] };
|
||||
expect(ctx.tokens[0]).toBe("[REDACTED]");
|
||||
expect(ctx.tokens[1]).toBe("[REDACTED]");
|
||||
expect(ctx.tokens[2]).toBe("plain");
|
||||
});
|
||||
|
||||
it(
|
||||
"terminates on cyclic context without throwing or looping",
|
||||
TIMEOUT,
|
||||
() => {
|
||||
const ctx: Record<string, unknown> = { name: "alice" };
|
||||
ctx["self"] = ctx;
|
||||
const err = makeError(ctx as Readonly<Record<string, unknown>>);
|
||||
const wire = toJSON(err);
|
||||
expect(wire.context).toBeDefined();
|
||||
const reduced = wire.context as { name: string };
|
||||
expect(reduced.name).toBe("alice");
|
||||
}
|
||||
);
|
||||
|
||||
it("leaves in-memory context untouched", TIMEOUT, () => {
|
||||
const tagged = markSensitive("abc");
|
||||
const err = makeError({ token: tagged });
|
||||
expect(
|
||||
unwrapSensitive(err.context?.["token"] as typeof tagged)
|
||||
).toBe("abc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TruncatedCause sentinel", () => {
|
||||
it("emits sentinel when maxDepth is exceeded", TIMEOUT, () => {
|
||||
const a = makeError();
|
||||
const b = makeError();
|
||||
const c = makeError();
|
||||
(a as unknown as { cause: unknown }).cause = b;
|
||||
(b as unknown as { cause: unknown }).cause = c;
|
||||
const wire = toJSON(a, { maxDepth: 2 });
|
||||
expect(wire.cause).toBeDefined();
|
||||
expect(wire.cause?.cause?.code).toBe("ERROR.CAUSE.TRUNCATED");
|
||||
expect(wire.cause?.cause?.name).toBe("TruncatedCause");
|
||||
});
|
||||
|
||||
it("emits sentinel when a cycle is detected", TIMEOUT, () => {
|
||||
const a = makeError();
|
||||
const b = makeError();
|
||||
(a as unknown as { cause: unknown }).cause = b;
|
||||
(b as unknown as { cause: unknown }).cause = a;
|
||||
const wire = toJSON(a);
|
||||
expect(wire.cause?.cause?.code).toBe("ERROR.CAUSE.TRUNCATED");
|
||||
});
|
||||
|
||||
it("sentinel survives round-trip through fromJSON", TIMEOUT, () => {
|
||||
const a = makeError();
|
||||
const b = makeError();
|
||||
(a as unknown as { cause: unknown }).cause = b;
|
||||
(b as unknown as { cause: unknown }).cause = a;
|
||||
const wire = toJSON(a);
|
||||
const reconstructed = fromJSON(wire);
|
||||
expect(reconstructed.cause?.cause?.code).toBe(
|
||||
"ERROR.CAUSE.TRUNCATED"
|
||||
);
|
||||
expect(reconstructed.cause?.cause?.name).toBe("TruncatedCause");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Wire format version discriminator", () => {
|
||||
it("preserves __suaveplanError: 1 through round-trip", TIMEOUT, () => {
|
||||
const err = makeError();
|
||||
const wire = toJSON(err);
|
||||
const reconstructed = fromJSON(wire);
|
||||
expect(reconstructed.__suaveplanError).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects payload missing __suaveplanError", TIMEOUT, () => {
|
||||
const bad = {
|
||||
code: "X.Y.Z",
|
||||
message: "m",
|
||||
timestamp: 0,
|
||||
name: "E",
|
||||
};
|
||||
expect(() => fromJSON(bad)).toThrow();
|
||||
try {
|
||||
fromJSON(bad);
|
||||
} catch (err) {
|
||||
expect((err as { code: string }).code).toBe(
|
||||
"ERROR.WIRE.MALFORMED"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
"rejects payload with wrong __suaveplanError version",
|
||||
TIMEOUT,
|
||||
() => {
|
||||
const bad = {
|
||||
__suaveplanError: 2,
|
||||
code: "X.Y.Z",
|
||||
message: "m",
|
||||
timestamp: 0,
|
||||
name: "E",
|
||||
};
|
||||
expect(() => fromJSON(bad)).toThrow();
|
||||
try {
|
||||
fromJSON(bad);
|
||||
} catch (err) {
|
||||
expect((err as { code: string }).code).toBe(
|
||||
"ERROR.WIRE.MALFORMED"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("fromJSON returns POJO discriminator (base class abstract)", () => {
|
||||
it(
|
||||
"reconstructed value is structurally complete but not an Error instance",
|
||||
TIMEOUT,
|
||||
() => {
|
||||
const err = makeError({ key: "value" });
|
||||
const wire = toJSON(err);
|
||||
const reconstructed = fromJSON(wire);
|
||||
expect(reconstructed.__suaveplanError).toBe(1);
|
||||
expect(reconstructed.code).toBe(wire.code);
|
||||
expect(reconstructed.message).toBe(wire.message);
|
||||
expect(reconstructed.context).toEqual(wire.context);
|
||||
expect(reconstructed.timestamp).toBe(wire.timestamp);
|
||||
expect(reconstructed.name).toBe(wire.name);
|
||||
expect(reconstructed instanceof Error).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
8
packages/foundation/core/error/src/constants/index.ts
Normal file
8
packages/foundation/core/error/src/constants/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** Maximum depth for cause-chain traversal and serialization. */
|
||||
export const MAX_CAUSE_DEPTH = 32;
|
||||
|
||||
/** String placed on the wire where a redacted value used to be. */
|
||||
export const REDACTED_LABEL = "[REDACTED]";
|
||||
|
||||
/** Current version of the wire format produced by `toJSON` / `toBinary`. */
|
||||
export const WIRE_VERSION = 1;
|
||||
6
packages/foundation/core/error/src/i18n/index.ts
Normal file
6
packages/foundation/core/error/src/i18n/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
clearMessageResolver,
|
||||
type MessageResolver,
|
||||
resolveMessage,
|
||||
setMessageResolver,
|
||||
} from "./resolver";
|
||||
83
packages/foundation/core/error/src/i18n/resolver.md
Normal file
83
packages/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.
|
||||
50
packages/foundation/core/error/src/i18n/resolver.test.ts
Normal file
50
packages/foundation/core/error/src/i18n/resolver.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
clearMessageResolver,
|
||||
resolveMessage,
|
||||
setMessageResolver,
|
||||
} from "./resolver.js";
|
||||
|
||||
afterEach(() => clearMessageResolver());
|
||||
|
||||
describe("message resolver", () => {
|
||||
it("returns the key verbatim when no resolver is installed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(resolveMessage("a.b.c")).toBe("a.b.c");
|
||||
});
|
||||
|
||||
it("applies an installed resolver", { timeout: 5000 }, () => {
|
||||
setMessageResolver((key) => `[${key}]`);
|
||||
expect(resolveMessage("x")).toBe("[x]");
|
||||
});
|
||||
|
||||
it("passes params through to the resolver", { timeout: 5000 }, () => {
|
||||
setMessageResolver((key, params) => `${key}:${params?.["n"] ?? 0}`);
|
||||
expect(resolveMessage("k", { n: 9 })).toBe("k:9");
|
||||
});
|
||||
|
||||
it("setMessageResolver returns the previous resolver", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const first = () => "one";
|
||||
const second = () => "two";
|
||||
setMessageResolver(first);
|
||||
const prev = setMessageResolver(second);
|
||||
expect(prev).toBe(first);
|
||||
expect(resolveMessage("_")).toBe("two");
|
||||
});
|
||||
|
||||
it("clearMessageResolver returns to default", { timeout: 5000 }, () => {
|
||||
setMessageResolver(() => "x");
|
||||
clearMessageResolver();
|
||||
expect(resolveMessage("k")).toBe("k");
|
||||
});
|
||||
|
||||
it("setMessageResolver returns undefined on first install", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const prev = setMessageResolver(() => "first");
|
||||
expect(prev).toBeUndefined();
|
||||
});
|
||||
});
|
||||
30
packages/foundation/core/error/src/i18n/resolver.ts
Normal file
30
packages/foundation/core/error/src/i18n/resolver.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { MessageResolver } from "@suaveplan/types";
|
||||
|
||||
export type { MessageResolver };
|
||||
|
||||
let activeResolver: MessageResolver | undefined;
|
||||
|
||||
/** Install a global message resolver. Returns the previous resolver, if any. */
|
||||
export function setMessageResolver(
|
||||
resolver: MessageResolver
|
||||
): MessageResolver | undefined {
|
||||
const prev = activeResolver;
|
||||
activeResolver = resolver;
|
||||
return prev;
|
||||
}
|
||||
|
||||
/** Remove any installed resolver — subsequent lookups return the key. */
|
||||
export function clearMessageResolver(): void {
|
||||
activeResolver = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a message key to its localized string. With no resolver installed,
|
||||
* the key is returned verbatim so the error still has a non-empty message.
|
||||
*/
|
||||
export function resolveMessage(
|
||||
key: string,
|
||||
params?: Readonly<Record<string, unknown>>
|
||||
): string {
|
||||
return activeResolver !== undefined ? activeResolver(key, params) : key;
|
||||
}
|
||||
46
packages/foundation/core/error/src/index.ts
Normal file
46
packages/foundation/core/error/src/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Base
|
||||
|
||||
export { ErrorPackageError } from "./base/package-error.js";
|
||||
export { defaultClock, SuaveplanError } from "./base/suaveplan-error.js";
|
||||
export type {
|
||||
ErrorContext,
|
||||
SuaveplanErrorOptions,
|
||||
} from "./base/suaveplan-error.types.js";
|
||||
// Causes
|
||||
export { type WalkCausesOptions, walkCauses } from "./causes/walk-causes.js";
|
||||
// Codes
|
||||
export { defineCodes } from "./codes/define-codes.js";
|
||||
export { ERROR_CODES, type ErrorCode } from "./codes/error-codes.js";
|
||||
// Constants
|
||||
export {
|
||||
MAX_CAUSE_DEPTH,
|
||||
REDACTED_LABEL,
|
||||
WIRE_VERSION,
|
||||
} from "./constants/index.js";
|
||||
// i18n
|
||||
export {
|
||||
clearMessageResolver,
|
||||
type MessageResolver,
|
||||
resolveMessage,
|
||||
setMessageResolver,
|
||||
} from "./i18n/resolver.js";
|
||||
|
||||
// OTel
|
||||
export { type SpanEvent, toSpanEvent } from "./otel/to-span-event.js";
|
||||
// Redact
|
||||
export {
|
||||
isSensitive,
|
||||
markSensitive,
|
||||
type Sensitive,
|
||||
unwrapSensitive,
|
||||
} from "./redact/sensitive.js";
|
||||
// Serialize
|
||||
export {
|
||||
fromBinary,
|
||||
fromJSON,
|
||||
type ReconstructedError,
|
||||
type ToJsonOptions,
|
||||
toBinary,
|
||||
toJSON,
|
||||
type WireError,
|
||||
} from "./serialize/index.js";
|
||||
1
packages/foundation/core/error/src/otel/index.ts
Normal file
1
packages/foundation/core/error/src/otel/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { type SpanEvent, toSpanEvent } from "./to-span-event";
|
||||
78
packages/foundation/core/error/src/otel/to-span-event.md
Normal file
78
packages/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.
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ErrorPackageError } from "../base/package-error.js";
|
||||
import { ERROR_CODES } from "../codes/error-codes.js";
|
||||
import { toSpanEvent } from "./to-span-event.js";
|
||||
|
||||
describe("toSpanEvent", () => {
|
||||
it("maps a SuaveplanError to exception-event attributes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new ErrorPackageError(
|
||||
ERROR_CODES["WIRE.MALFORMED"],
|
||||
"error.error.wire.malformed",
|
||||
{ traceId: "t-1" }
|
||||
);
|
||||
const ev = toSpanEvent(err);
|
||||
expect(ev.name).toBe("exception");
|
||||
expect(ev.attributes["exception.type"]).toBe("ErrorPackageError");
|
||||
expect(ev.attributes["exception.code"]).toBe("ERROR.WIRE.MALFORMED");
|
||||
expect(ev.attributes["exception.traceId"]).toBe("t-1");
|
||||
expect(typeof ev.attributes["exception.stacktrace"]).toBe("string");
|
||||
});
|
||||
|
||||
it("falls back to empty stack and UNKNOWN code for bare values", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ev = toSpanEvent({});
|
||||
expect(ev.attributes["exception.type"]).toBe("Object");
|
||||
expect(ev.attributes["exception.stacktrace"]).toBe("");
|
||||
expect(ev.attributes["exception.code"]).toBe("UNKNOWN");
|
||||
expect(ev.attributes["exception.traceId"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses err.name when the constructor name is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bare = Object.create(null) as Record<string, unknown>;
|
||||
bare["name"] = "CustomName";
|
||||
bare["message"] = "m";
|
||||
const ev = toSpanEvent(bare);
|
||||
expect(ev.attributes["exception.type"]).toBe("CustomName");
|
||||
});
|
||||
|
||||
it("falls back to 'Error' when neither constructor.name nor name is present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bare = Object.create(null) as Record<string, unknown>;
|
||||
const ev = toSpanEvent(bare);
|
||||
expect(ev.attributes["exception.type"]).toBe("Error");
|
||||
});
|
||||
|
||||
it("stringifies non-string messages", { timeout: 5000 }, () => {
|
||||
const ev = toSpanEvent({ message: 42 });
|
||||
expect(ev.attributes["exception.message"]).toBe("42");
|
||||
});
|
||||
|
||||
it("coerces nullish message to empty string", { timeout: 5000 }, () => {
|
||||
const ev = toSpanEvent({ message: undefined });
|
||||
expect(ev.attributes["exception.message"]).toBe("");
|
||||
});
|
||||
});
|
||||
49
packages/foundation/core/error/src/otel/to-span-event.ts
Normal file
49
packages/foundation/core/error/src/otel/to-span-event.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export interface SpanEvent {
|
||||
readonly name: "exception";
|
||||
readonly attributes: {
|
||||
readonly "exception.type": string;
|
||||
readonly "exception.message": string;
|
||||
readonly "exception.stacktrace": string;
|
||||
readonly "exception.code": string;
|
||||
readonly "exception.traceId"?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate any error-shaped value to the OpenTelemetry `exception` span
|
||||
* event payload. No OTel SDK is imported — just the attribute shape.
|
||||
*/
|
||||
export function toSpanEvent(err: unknown): SpanEvent {
|
||||
const src = err as {
|
||||
constructor?: { name?: string };
|
||||
name?: string;
|
||||
message?: unknown;
|
||||
stack?: unknown;
|
||||
code?: unknown;
|
||||
traceId?: unknown;
|
||||
};
|
||||
const typeName =
|
||||
(typeof src.constructor?.name === "string" && src.constructor.name) ||
|
||||
(typeof src.name === "string" && src.name) ||
|
||||
"Error";
|
||||
|
||||
const attributes: {
|
||||
"exception.type": string;
|
||||
"exception.message": string;
|
||||
"exception.stacktrace": string;
|
||||
"exception.code": string;
|
||||
"exception.traceId"?: string;
|
||||
} = {
|
||||
"exception.type": typeName,
|
||||
"exception.message":
|
||||
typeof src.message === "string"
|
||||
? src.message
|
||||
: String(src.message ?? ""),
|
||||
"exception.stacktrace": typeof src.stack === "string" ? src.stack : "",
|
||||
"exception.code": typeof src.code === "string" ? src.code : "UNKNOWN",
|
||||
};
|
||||
if (typeof src.traceId === "string") {
|
||||
attributes["exception.traceId"] = src.traceId;
|
||||
}
|
||||
return { name: "exception", attributes };
|
||||
}
|
||||
6
packages/foundation/core/error/src/redact/index.ts
Normal file
6
packages/foundation/core/error/src/redact/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
isSensitive,
|
||||
markSensitive,
|
||||
type Sensitive,
|
||||
unwrapSensitive,
|
||||
} from "./sensitive";
|
||||
76
packages/foundation/core/error/src/redact/sensitive.md
Normal file
76
packages/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.
|
||||
32
packages/foundation/core/error/src/redact/sensitive.test.ts
Normal file
32
packages/foundation/core/error/src/redact/sensitive.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { isSensitive, markSensitive, unwrapSensitive } from "./sensitive.js";
|
||||
|
||||
describe("sensitive", () => {
|
||||
it("markSensitive wraps a value", { timeout: 5000 }, () => {
|
||||
const s = markSensitive("secret");
|
||||
expect(isSensitive(s)).toBe(true);
|
||||
});
|
||||
|
||||
it("unwrapSensitive returns the original value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = markSensitive({ token: "abc" });
|
||||
expect(unwrapSensitive(s)).toEqual({ token: "abc" });
|
||||
});
|
||||
|
||||
it("unwrapSensitive is pass-through for non-sensitive values", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(unwrapSensitive("plain")).toBe("plain");
|
||||
expect(unwrapSensitive(42)).toBe(42);
|
||||
});
|
||||
|
||||
it("isSensitive rejects null, primitives, and plain objects", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(isSensitive(null)).toBe(false);
|
||||
expect(isSensitive(undefined)).toBe(false);
|
||||
expect(isSensitive("x")).toBe(false);
|
||||
expect(isSensitive({ value: "x" })).toBe(false);
|
||||
});
|
||||
});
|
||||
26
packages/foundation/core/error/src/redact/sensitive.ts
Normal file
26
packages/foundation/core/error/src/redact/sensitive.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
const SENSITIVE_TAG = Symbol.for("@suaveplan/error.sensitive");
|
||||
|
||||
/** Marker shape produced by `markSensitive`. Opaque to consumers. */
|
||||
export interface Sensitive<T> {
|
||||
readonly [SENSITIVE_TAG]: true;
|
||||
readonly value: T;
|
||||
}
|
||||
|
||||
/** Wrap a value so the serialization boundary will redact it on the wire. */
|
||||
export function markSensitive<T>(value: T): Sensitive<T> {
|
||||
return { [SENSITIVE_TAG]: true, value };
|
||||
}
|
||||
|
||||
/** Return the original value inside a `Sensitive<T>`, or the value itself. */
|
||||
export function unwrapSensitive<T>(value: Sensitive<T> | T): T {
|
||||
return isSensitive(value) ? value.value : value;
|
||||
}
|
||||
|
||||
/** Type guard for the `Sensitive<T>` tag. */
|
||||
export function isSensitive<T>(value: unknown): value is Sensitive<T> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
(value as { [SENSITIVE_TAG]?: boolean })[SENSITIVE_TAG] === true
|
||||
);
|
||||
}
|
||||
58
packages/foundation/core/error/src/serialize/from-binary.md
Normal file
58
packages/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.
|
||||
31
packages/foundation/core/error/src/serialize/from-binary.ts
Normal file
31
packages/foundation/core/error/src/serialize/from-binary.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ErrorPackageError } from "../base/package-error.js";
|
||||
import { ERROR_CODES } from "../codes/error-codes.js";
|
||||
import { fromJSON, type ReconstructedError } from "./from-json.js";
|
||||
|
||||
/**
|
||||
* Decode a binary payload produced by `toBinary`. Truncated or non-UTF-8
|
||||
* input rejects with `ErrorPackageError("ERROR.BINARY.TRUNCATED")`.
|
||||
*/
|
||||
export function fromBinary(bytes: Uint8Array): ReconstructedError {
|
||||
let json: string;
|
||||
try {
|
||||
json = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch (cause) {
|
||||
throw new ErrorPackageError(
|
||||
ERROR_CODES["BINARY.TRUNCATED"],
|
||||
"error.error.binary.truncated",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch (cause) {
|
||||
throw new ErrorPackageError(
|
||||
ERROR_CODES["BINARY.TRUNCATED"],
|
||||
"error.error.binary.truncated",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
return fromJSON(parsed);
|
||||
}
|
||||
70
packages/foundation/core/error/src/serialize/from-json.md
Normal file
70
packages/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.
|
||||
56
packages/foundation/core/error/src/serialize/from-json.ts
Normal file
56
packages/foundation/core/error/src/serialize/from-json.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ErrorPackageError } from "../base/package-error.js";
|
||||
import { ERROR_CODES } from "../codes/error-codes.js";
|
||||
import { type WireError, wireErrorSchema } from "./wire.schemas.js";
|
||||
|
||||
/**
|
||||
* Reconstruct an error-shaped object from a wire payload. Zod-validated.
|
||||
* Malformed input rejects with `ErrorPackageError("ERROR.WIRE.MALFORMED")`.
|
||||
*/
|
||||
export function fromJSON(value: unknown): ReconstructedError {
|
||||
const parsed = wireErrorSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new ErrorPackageError(
|
||||
ERROR_CODES["WIRE.MALFORMED"],
|
||||
"error.error.wire.malformed",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
return reconstruct(parsed.data);
|
||||
}
|
||||
|
||||
export interface ReconstructedError {
|
||||
readonly __suaveplanError: 1;
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly context?: Readonly<Record<string, unknown>>;
|
||||
readonly timestamp: number;
|
||||
readonly traceId?: string;
|
||||
readonly stack?: string;
|
||||
readonly name: string;
|
||||
readonly cause?: ReconstructedError;
|
||||
}
|
||||
|
||||
function reconstruct(wire: WireError): ReconstructedError {
|
||||
const out: {
|
||||
__suaveplanError: 1;
|
||||
code: string;
|
||||
message: string;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
timestamp: number;
|
||||
traceId?: string;
|
||||
stack?: string;
|
||||
name: string;
|
||||
cause?: ReconstructedError;
|
||||
} = {
|
||||
__suaveplanError: 1,
|
||||
code: wire.code,
|
||||
message: wire.message,
|
||||
timestamp: wire.timestamp,
|
||||
name: wire.name,
|
||||
};
|
||||
if (wire.context !== undefined) out.context = wire.context;
|
||||
if (wire.traceId !== undefined) out.traceId = wire.traceId;
|
||||
if (wire.stack !== undefined) out.stack = wire.stack;
|
||||
if (wire.cause !== undefined) out.cause = reconstruct(wire.cause);
|
||||
return out;
|
||||
}
|
||||
5
packages/foundation/core/error/src/serialize/index.ts
Normal file
5
packages/foundation/core/error/src/serialize/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { fromBinary } from "./from-binary.js";
|
||||
export { fromJSON, type ReconstructedError } from "./from-json.js";
|
||||
export { toBinary } from "./to-binary.js";
|
||||
export { type ToJsonOptions, toJSON } from "./to-json.js";
|
||||
export type { WireError } from "./wire.schemas.js";
|
||||
198
packages/foundation/core/error/src/serialize/serialize.test.ts
Normal file
198
packages/foundation/core/error/src/serialize/serialize.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { ErrorPackageError } from "../base/package-error.js";
|
||||
import { SuaveplanError } from "../base/suaveplan-error.js";
|
||||
import { ERROR_CODES } from "../codes/error-codes.js";
|
||||
import { markSensitive } from "../redact/sensitive.js";
|
||||
import { fromBinary } from "./from-binary.js";
|
||||
import { fromJSON } from "./from-json.js";
|
||||
import { toBinary } from "./to-binary.js";
|
||||
import { toJSON } from "./to-json.js";
|
||||
|
||||
class DemoError extends SuaveplanError<"DEMO.A" | "DEMO.B"> {}
|
||||
|
||||
describe("toJSON / fromJSON", () => {
|
||||
it("preserves code, message, timestamp, name, traceId, context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new DemoError("DEMO.A", "demo.a", {
|
||||
clock: () => 100,
|
||||
context: { user: "x" },
|
||||
traceId: "t-1",
|
||||
});
|
||||
const wire = toJSON(err);
|
||||
expect(wire.__suaveplanError).toBe(1);
|
||||
expect(wire.code).toBe("DEMO.A");
|
||||
expect(wire.message).toBe("demo.a");
|
||||
expect(wire.timestamp).toBe(100);
|
||||
expect(wire.traceId).toBe("t-1");
|
||||
expect(wire.name).toBe("DemoError");
|
||||
expect(wire.context).toEqual({ user: "x" });
|
||||
});
|
||||
|
||||
it("includes stack when present", { timeout: 5000 }, () => {
|
||||
const err = new DemoError("DEMO.A", "k");
|
||||
const wire = toJSON(err);
|
||||
expect(typeof wire.stack).toBe("string");
|
||||
});
|
||||
|
||||
it("redacts sensitive context fields on the wire", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new DemoError("DEMO.A", "k", {
|
||||
context: { token: markSensitive("xyz"), user: "u" },
|
||||
});
|
||||
const wire = toJSON(err);
|
||||
expect(wire.context).toEqual({ token: "[REDACTED]", user: "u" });
|
||||
});
|
||||
|
||||
it("serializes the cause chain", { timeout: 5000 }, () => {
|
||||
const root = new DemoError("DEMO.B", "root.key");
|
||||
const wrapped = new DemoError("DEMO.A", "wrap.key", { cause: root });
|
||||
const wire = toJSON(wrapped);
|
||||
expect(wire.cause?.code).toBe("DEMO.B");
|
||||
});
|
||||
|
||||
it("truncates an over-deep chain", { timeout: 5000 }, () => {
|
||||
const a: { cause?: unknown } = {};
|
||||
const b: { cause?: unknown } = {};
|
||||
a.cause = b;
|
||||
b.cause = a;
|
||||
const wire = toJSON(a, { maxDepth: 2 });
|
||||
expect(wire.cause?.cause?.code).toBe("ERROR.CAUSE.TRUNCATED");
|
||||
});
|
||||
|
||||
it("emits fallback fields for non-error cause values", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = toJSON("just-a-string");
|
||||
expect(wire.code).toBe("ERROR.CAUSE.TRUNCATED");
|
||||
});
|
||||
|
||||
it("emits defaults when fields are wrong types", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = toJSON({ code: 123, message: null, timestamp: "x" });
|
||||
expect(wire.code).toBe("UNKNOWN");
|
||||
expect(wire.message).toBe("");
|
||||
expect(wire.timestamp).toBe(0);
|
||||
expect(wire.name).toBe("Error");
|
||||
});
|
||||
|
||||
it("fromJSON reconstructs a matching wire shape", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new DemoError("DEMO.A", "k", { clock: () => 7 });
|
||||
const back = fromJSON(toJSON(err));
|
||||
expect(back.code).toBe("DEMO.A");
|
||||
expect(back.timestamp).toBe(7);
|
||||
expect(back.name).toBe("DemoError");
|
||||
});
|
||||
|
||||
it("fromJSON preserves cause, context, traceId, stack", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new DemoError("DEMO.B", "c", { clock: () => 1 });
|
||||
const err = new DemoError("DEMO.A", "p", {
|
||||
clock: () => 2,
|
||||
cause,
|
||||
context: { a: 1 },
|
||||
traceId: "t",
|
||||
});
|
||||
const back = fromJSON(toJSON(err));
|
||||
expect(back.cause?.code).toBe("DEMO.B");
|
||||
expect(back.context).toEqual({ a: 1 });
|
||||
expect(back.traceId).toBe("t");
|
||||
expect(typeof back.stack).toBe("string");
|
||||
});
|
||||
|
||||
it("fromJSON handles wire payloads without optional fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const back = fromJSON({
|
||||
__suaveplanError: 1,
|
||||
code: "X.Y",
|
||||
message: "m",
|
||||
timestamp: 0,
|
||||
name: "X",
|
||||
});
|
||||
expect(back.context).toBeUndefined();
|
||||
expect(back.traceId).toBeUndefined();
|
||||
expect(back.stack).toBeUndefined();
|
||||
expect(back.cause).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fromJSON rejects malformed input with ErrorPackageError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => fromJSON({ code: 123 })).toThrow(ErrorPackageError);
|
||||
try {
|
||||
fromJSON({ code: 123 });
|
||||
} catch (err) {
|
||||
expect((err as ErrorPackageError).code).toBe(
|
||||
ERROR_CODES["WIRE.MALFORMED"]
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("toBinary / fromBinary", () => {
|
||||
it("round-trips equivalently to the JSON form", { timeout: 5000 }, () => {
|
||||
const err = new DemoError("DEMO.A", "k", { clock: () => 5 });
|
||||
const wire = toJSON(err);
|
||||
const back = fromBinary(toBinary(err));
|
||||
expect(back).toEqual(fromJSON(wire));
|
||||
});
|
||||
|
||||
it("rejects non-UTF8 input with BINARY.TRUNCATED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Lone continuation byte — invalid UTF-8.
|
||||
expect(() => fromBinary(new Uint8Array([0xff]))).toThrow(
|
||||
ErrorPackageError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid JSON bytes with BINARY.TRUNCATED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bytes = new TextEncoder().encode("{not-json");
|
||||
try {
|
||||
fromBinary(bytes);
|
||||
expect.unreachable();
|
||||
} catch (err) {
|
||||
expect((err as ErrorPackageError).code).toBe(
|
||||
ERROR_CODES["BINARY.TRUNCATED"]
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("toJSON — property-based", () => {
|
||||
it("round-trips arbitrary code+message+traceId errors", {
|
||||
timeout: 10000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 32 }),
|
||||
fc.string({ maxLength: 128 }),
|
||||
fc.option(fc.string({ maxLength: 32 }), { nil: undefined }),
|
||||
fc.integer({ min: 0, max: 2 ** 40 }),
|
||||
(code, msg, traceId, ts) => {
|
||||
const err = new DemoError("DEMO.A", msg, {
|
||||
clock: () => ts,
|
||||
...(traceId !== undefined ? { traceId } : {}),
|
||||
});
|
||||
const back = fromJSON(toJSON(err));
|
||||
return (
|
||||
back.code === "DEMO.A" &&
|
||||
back.message === msg &&
|
||||
back.timestamp === ts &&
|
||||
back.traceId === traceId
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 40 }
|
||||
);
|
||||
});
|
||||
});
|
||||
53
packages/foundation/core/error/src/serialize/to-binary.md
Normal file
53
packages/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.
|
||||
11
packages/foundation/core/error/src/serialize/to-binary.ts
Normal file
11
packages/foundation/core/error/src/serialize/to-binary.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { type ToJsonOptions, toJSON } from "./to-json.js";
|
||||
|
||||
/**
|
||||
* Encode an error to a compact binary payload (UTF-8 bytes of the canonical
|
||||
* JSON form). Round-trips losslessly via `fromBinary`.
|
||||
*/
|
||||
export function toBinary(err: unknown, options?: ToJsonOptions): Uint8Array {
|
||||
const wire = toJSON(err, options);
|
||||
const json = JSON.stringify(wire);
|
||||
return new TextEncoder().encode(json);
|
||||
}
|
||||
74
packages/foundation/core/error/src/serialize/to-json.md
Normal file
74
packages/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.
|
||||
112
packages/foundation/core/error/src/serialize/to-json.ts
Normal file
112
packages/foundation/core/error/src/serialize/to-json.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { SuaveplanError } from "../base/suaveplan-error.js";
|
||||
import {
|
||||
MAX_CAUSE_DEPTH,
|
||||
REDACTED_LABEL,
|
||||
WIRE_VERSION,
|
||||
} from "../constants/index.js";
|
||||
import { isSensitive } from "../redact/sensitive.js";
|
||||
import type { WireError } from "./wire.schemas.js";
|
||||
|
||||
export interface ToJsonOptions {
|
||||
readonly maxDepth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a `SuaveplanError` (or any cause-bearing value) to a wire-safe
|
||||
* plain object. Redacted context fields become the literal `"[REDACTED]"`.
|
||||
* Cycles and over-deep chains are truncated at `maxDepth`.
|
||||
*/
|
||||
export function toJSON(err: unknown, options: ToJsonOptions = {}): WireError {
|
||||
const maxDepth = options.maxDepth ?? MAX_CAUSE_DEPTH;
|
||||
return encode(err, maxDepth, new WeakSet<object>());
|
||||
}
|
||||
|
||||
function encode(
|
||||
err: unknown,
|
||||
remaining: number,
|
||||
seen: WeakSet<object>
|
||||
): WireError {
|
||||
if (remaining <= 0 || !isObject(err) || seen.has(err)) {
|
||||
return {
|
||||
__suaveplanError: WIRE_VERSION,
|
||||
code: "ERROR.CAUSE.TRUNCATED",
|
||||
message: "[cause chain truncated]",
|
||||
timestamp: 0,
|
||||
name: "TruncatedCause",
|
||||
};
|
||||
}
|
||||
seen.add(err);
|
||||
|
||||
const src = err as {
|
||||
code?: unknown;
|
||||
message?: unknown;
|
||||
context?: unknown;
|
||||
timestamp?: unknown;
|
||||
traceId?: unknown;
|
||||
stack?: unknown;
|
||||
name?: unknown;
|
||||
cause?: unknown;
|
||||
};
|
||||
|
||||
const wire: {
|
||||
__suaveplanError: 1;
|
||||
code: string;
|
||||
message: string;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
timestamp: number;
|
||||
traceId?: string;
|
||||
stack?: string;
|
||||
name: string;
|
||||
cause?: WireError;
|
||||
} = {
|
||||
__suaveplanError: WIRE_VERSION,
|
||||
code: typeof src.code === "string" ? src.code : "UNKNOWN",
|
||||
message: typeof src.message === "string" ? src.message : "",
|
||||
timestamp: typeof src.timestamp === "number" ? src.timestamp : 0,
|
||||
name: typeof src.name === "string" ? src.name : "Error",
|
||||
};
|
||||
|
||||
if (isObject(src.context)) {
|
||||
wire.context = redactContext(src.context, new WeakSet<object>());
|
||||
}
|
||||
if (typeof src.traceId === "string") {
|
||||
wire.traceId = src.traceId;
|
||||
}
|
||||
if (typeof src.stack === "string") {
|
||||
wire.stack = src.stack;
|
||||
}
|
||||
if (src.cause !== undefined) {
|
||||
wire.cause = encode(src.cause, remaining - 1, seen);
|
||||
}
|
||||
|
||||
return wire;
|
||||
}
|
||||
|
||||
function redactContext(
|
||||
ctx: object,
|
||||
seen: WeakSet<object>
|
||||
): Readonly<Record<string, unknown>> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(ctx)) {
|
||||
out[key] = redactValue(value, seen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function redactValue(value: unknown, seen: WeakSet<object>): unknown {
|
||||
if (isSensitive(value)) return REDACTED_LABEL;
|
||||
if (!isObject(value)) return value;
|
||||
if (seen.has(value)) return value;
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => redactValue(item, seen));
|
||||
}
|
||||
return redactContext(value, seen);
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is object {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
// Re-export for convenience (some consumers want to narrow before serializing).
|
||||
export { SuaveplanError };
|
||||
30
packages/foundation/core/error/src/serialize/wire.schemas.ts
Normal file
30
packages/foundation/core/error/src/serialize/wire.schemas.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import z from "zod";
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: required for z.lazy self-reference
|
||||
const wireErrorSchemaImpl: z.ZodType<any> = z.lazy(() =>
|
||||
z.object({
|
||||
__suaveplanError: z.literal(1),
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
context: z.record(z.string(), z.unknown()).optional(),
|
||||
timestamp: z.number(),
|
||||
traceId: z.string().optional(),
|
||||
stack: z.string().optional(),
|
||||
name: z.string(),
|
||||
cause: wireErrorSchemaImpl.optional(),
|
||||
})
|
||||
);
|
||||
|
||||
export interface WireError {
|
||||
readonly __suaveplanError: 1;
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly context?: Readonly<Record<string, unknown>>;
|
||||
readonly timestamp: number;
|
||||
readonly traceId?: string;
|
||||
readonly stack?: string;
|
||||
readonly name: string;
|
||||
readonly cause?: WireError;
|
||||
}
|
||||
|
||||
export const wireErrorSchema = wireErrorSchemaImpl as z.ZodType<WireError>;
|
||||
34
packages/foundation/core/error/tests/dep-graph.test.ts
Normal file
34
packages/foundation/core/error/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("@suaveplan/types is the only @suaveplan/* runtime dep; no peer deps", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const runtimeScoped = Object.keys(pkg.dependencies ?? {}).filter(
|
||||
(name) => name.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(runtimeScoped).toEqual(["@suaveplan/types"]);
|
||||
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
|
||||
(name) => name.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(peerScoped).toEqual([]);
|
||||
});
|
||||
|
||||
it("runtime deps are limited to @suaveplan/types + tslib + zod", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(runtime).toEqual(["@suaveplan/types", "tslib", "zod"]);
|
||||
});
|
||||
});
|
||||
41
packages/foundation/core/error/tests/no-disallowed.test.ts
Normal file
41
packages/foundation/core/error/tests/no-disallowed.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC = join(__dirname, "..", "src");
|
||||
|
||||
const BANNED: ReadonlyArray<RegExp> = [
|
||||
/\bnode:fs\b/,
|
||||
/\bnode:path\b/,
|
||||
/\bnode:url\b/,
|
||||
/\bprocess\./,
|
||||
/\bBuffer\b/,
|
||||
/\bdocument\b/,
|
||||
/\bwindow\b/,
|
||||
];
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) yield* walk(full);
|
||||
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
describe("no-disallowed", () => {
|
||||
it("src/ does not reference Node or browser built-ins", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hits: string[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
for (const pattern of BANNED) {
|
||||
if (pattern.test(content)) {
|
||||
hits.push(`${file} matched ${pattern}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
3
packages/foundation/core/error/tests/setup/polyfills.ts
Normal file
3
packages/foundation/core/error/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// No polyfills required — `@suaveplan/error` runs on bare Node/Bun/browser.
|
||||
// File exists so vitest's `setupFiles` has a stable target across packages.
|
||||
export {};
|
||||
21
packages/foundation/core/error/tests/tree-shake.test.ts
Normal file
21
packages/foundation/core/error/tests/tree-shake.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("tree-shake", () => {
|
||||
it("sideEffects is false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("exports are per-module entry points", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { exports?: Record<string, unknown> };
|
||||
expect(pkg.exports).toBeDefined();
|
||||
// Root + several subpaths (sanity: at least 5).
|
||||
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
12
packages/foundation/core/error/tsconfig.lib.json
Normal file
12
packages/foundation/core/error/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
33
packages/foundation/core/error/vite.config.ts
Normal file
33
packages/foundation/core/error/vite.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/error",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: "src/index.ts",
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: ["@suaveplan/types", "tslib", "zod", /^node:/],
|
||||
},
|
||||
},
|
||||
});
|
||||
39
packages/foundation/core/error/vitest.config.ts
Normal file
39
packages/foundation/core/error/vitest.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: [
|
||||
"src/__benchmarks__/**",
|
||||
"benchmarks/**",
|
||||
"e2e/**",
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user