180 lines
7.3 KiB
Markdown
180 lines
7.3 KiB
Markdown
# Design: enterprise-error
|
|
|
|
## Module layout
|
|
|
|
```text
|
|
packages/universal/error/
|
|
├── package.json # deps: tslib, zod. devDeps: vitest, fast-check, testing
|
|
├── tsconfig.json / tsconfig.lib.json
|
|
├── vite.config.ts # library build (esm + dts)
|
|
├── vitest.config.ts # thresholds 100/100/100/100, istanbul provider
|
|
├── README.md
|
|
├── src/
|
|
│ ├── index.ts # barrel — public API
|
|
│ ├── constants/
|
|
│ │ └── index.ts # MAX_CAUSE_DEPTH, REDACTED_LABEL
|
|
│ ├── base/
|
|
│ │ ├── suaveplan-error.ts # SuaveplanError<TCode> abstract class
|
|
│ │ ├── suaveplan-error.md
|
|
│ │ ├── suaveplan-error.test.ts
|
|
│ │ └── suaveplan-error.types.ts # options, wire shape types
|
|
│ ├── codes/
|
|
│ │ ├── define-codes.ts # defineCodes<NS, R[]>() factory
|
|
│ │ ├── define-codes.md
|
|
│ │ └── define-codes.test.ts
|
|
│ ├── causes/
|
|
│ │ ├── walk-causes.ts # generator, cycle-safe, depth-bounded
|
|
│ │ ├── walk-causes.md
|
|
│ │ └── walk-causes.test.ts
|
|
│ ├── serialize/
|
|
│ │ ├── to-json.ts
|
|
│ │ ├── to-json.md
|
|
│ │ ├── from-json.ts
|
|
│ │ ├── from-json.md
|
|
│ │ ├── to-binary.ts # MessagePack-compatible encoder
|
|
│ │ ├── to-binary.md
|
|
│ │ ├── from-binary.ts
|
|
│ │ ├── from-binary.md
|
|
│ │ ├── wire.schemas.ts # Zod schemas (opt-in import)
|
|
│ │ └── serialize.test.ts # cross-cutting round-trip tests
|
|
│ ├── redact/
|
|
│ │ ├── sensitive.ts # markSensitive / unwrapSensitive
|
|
│ │ ├── sensitive.md
|
|
│ │ └── sensitive.test.ts
|
|
│ ├── otel/
|
|
│ │ ├── to-span-event.ts
|
|
│ │ ├── to-span-event.md
|
|
│ │ └── to-span-event.test.ts
|
|
│ ├── i18n/
|
|
│ │ ├── resolver.ts # setMessageResolver, resolveMessage
|
|
│ │ ├── resolver.md
|
|
│ │ └── resolver.test.ts
|
|
│ └── __benchmarks__/
|
|
│ └── serialize.bench.ts
|
|
├── tests/
|
|
│ ├── setup/polyfills.ts
|
|
│ ├── dep-graph.test.ts
|
|
│ ├── no-disallowed.test.ts # grep guard for node:/Buffer/window/etc.
|
|
│ └── tree-shake.test.ts
|
|
└── CHANGELOG.md
|
|
```
|
|
|
|
## Key design decisions
|
|
|
|
### Generic `code` parameter on `SuaveplanError`
|
|
|
|
```ts
|
|
export abstract class SuaveplanError<TCode extends string = string> extends Error {
|
|
public readonly code: TCode;
|
|
public readonly context?: Readonly<Record<string, unknown>>;
|
|
public readonly timestamp: number;
|
|
public readonly traceId?: string;
|
|
// ...
|
|
}
|
|
```
|
|
|
|
Subclasses narrow `TCode` so `switch (err.code)` gets exhaustiveness checking without manual casts. Default `string` keeps the base usable when a consumer doesn't care about discriminants.
|
|
|
|
### `defineCodes` returns a frozen literal-typed record
|
|
|
|
```ts
|
|
export function defineCodes<NS extends string, R extends readonly string[]>(
|
|
ns: NS,
|
|
reasons: R
|
|
): { readonly [K in R[number]]: `${NS}.${K}` } {
|
|
const out = Object.create(null);
|
|
for (const r of reasons) out[r] = `${ns}.${r}`;
|
|
return Object.freeze(out);
|
|
}
|
|
```
|
|
|
|
Return type is inferred structurally — no manual enum declarations, no drift between the type and the runtime value.
|
|
|
|
### Cause-chain traversal
|
|
|
|
`walkCauses` is a generator. The cycle guard uses a `WeakSet<object>` seeded with the root; non-object causes (strings, numbers) are allowed but not tracked. `maxDepth` is inclusive of the root and defaults to 32.
|
|
|
|
### Wire format
|
|
|
|
JSON shape:
|
|
|
|
```ts
|
|
{
|
|
__suaveplanError: 1, // format version
|
|
code: string,
|
|
message: string,
|
|
context?: Record<string, unknown>, // redactions applied
|
|
timestamp: number,
|
|
traceId?: string,
|
|
stack?: string,
|
|
cause?: WireError, // recursive
|
|
}
|
|
```
|
|
|
|
Binary format: a deterministic MessagePack-compatible encoding implemented in `to-binary.ts`. Only the subset needed for the wire shape is implemented (`nil`, `bool`, `int` up to 53 bits, `float64`, `str`, `bin`, `array`, `map`). No external msgpack dependency.
|
|
|
|
### Redaction
|
|
|
|
`markSensitive(value)` returns `{ __sensitive: true, value }`. At serialization time, `toJSON`/`toBinary` replace tagged values with the string `"[REDACTED]"`. `unwrapSensitive` returns the original value for in-memory access.
|
|
|
|
### Deterministic clock
|
|
|
|
Every constructor accepts `{ clock?: () => number }`. The default module-level reference is a thin wrapper — `const defaultClock = () => Date.now()` — that lives in one place and can be swapped with property mocking in tests. No other file reads `Date.now()` directly.
|
|
|
|
### i18n hook
|
|
|
|
Module-level state: `let resolver: MessageResolver | undefined`. `setMessageResolver(fn)` installs one; `resolveMessage(key, params?)` calls it or returns the key verbatim. No `@suaveplan/i18n` dependency — that package will register a resolver at app bootstrap.
|
|
|
|
## Coverage strategy
|
|
|
|
Thresholds locked at 100 for every metric. Testing approach:
|
|
|
|
1. **Enumeration tests** for every branch (normal + error paths)
|
|
2. **Property-based tests** via fast-check for round-trip invariants (JSON, binary, cause-chain)
|
|
3. **No `istanbul ignore` pragmas** — dead branches are refactored away (use `as` assertions with invariant comments when type-narrowing is the only motivation for a defensive check)
|
|
4. **Cross-cutting guards in `tests/`** for dep-graph, tree-shake, and no-disallowed-module-references
|
|
|
|
## Public exports (initial surface)
|
|
|
|
```ts
|
|
// base
|
|
export { SuaveplanError } from "./base/suaveplan-error.js";
|
|
export type { SuaveplanErrorOptions, ErrorContext } from "./base/suaveplan-error.types.js";
|
|
|
|
// codes
|
|
export { defineCodes } from "./codes/define-codes.js";
|
|
|
|
// causes
|
|
export { walkCauses } from "./causes/walk-causes.js";
|
|
|
|
// serialize
|
|
export { toJSON } from "./serialize/to-json.js";
|
|
export { fromJSON } from "./serialize/from-json.js";
|
|
export { toBinary } from "./serialize/to-binary.js";
|
|
export { fromBinary } from "./serialize/from-binary.js";
|
|
export type { WireError } from "./serialize/wire.schemas.js";
|
|
|
|
// redact
|
|
export { markSensitive, unwrapSensitive, isSensitive } from "./redact/sensitive.js";
|
|
|
|
// otel
|
|
export { toSpanEvent } from "./otel/to-span-event.js";
|
|
export type { SpanEvent } from "./otel/to-span-event.js";
|
|
|
|
// i18n
|
|
export { setMessageResolver, resolveMessage, clearMessageResolver } from "./i18n/resolver.js";
|
|
|
|
// package-local error subclass for this package's own failure modes
|
|
export { ErrorPackageError } from "./base/package-error.js";
|
|
export { ERROR_CODES } from "./codes/error-codes.js";
|
|
```
|
|
|
|
`ErrorPackageError` is the one concrete subclass the package ships for its own internal failures (e.g. `ERROR.WIRE.MALFORMED`, `ERROR.CAUSE.CYCLE_DETECTED`). Every other package will extend `SuaveplanError` directly with its own codes.
|
|
|
|
## Non-goals
|
|
|
|
- Not implementing a full MessagePack codec — only the subset needed for wire payloads
|
|
- Not shipping an i18n catalog — only the resolver hook
|
|
- Not shipping an OTel SDK — only the span-event adapter shape
|
|
- Not a logger — `toJSON` produces a shape that `@suaveplan/logger` can consume later
|