14 KiB
error
Purpose
Foundation error substrate for the Suaveplan monorepo — SuaveplanError base class, stable code registry via defineCodes, cause-chain traversal, Zod-validated wire format (JSON + binary), redaction at the serialization boundary, OTel span-event adapter, and i18n resolver hook. Has zero @suaveplan/* runtime dependencies (carve-out for the types-only @suaveplan/types).
Requirements
Requirement: Zero workspace and runtime dependencies
The @suaveplan/error package's dependencies MUST be limited to tslib, zod, and @suaveplan/types. No peerDependencies. No other @suaveplan/* packages at runtime. The @suaveplan/types carve-out is permitted exclusively because it ships zero runtime code (types-only build, byte-size guarded by its own conformance test) and therefore cannot reintroduce the circular foundation-of-foundations hazard the rule otherwise prevents.
Verified by: tests/no-disallowed.test.ts and the package.json dep-set under bunx turbo ls --filter=@suaveplan/error....
Scenario: Dep-graph guard
- WHEN
bunx turbo ls --filter=@suaveplan/error...is run - THEN the only
@suaveplan/*packages in the forward closure are@suaveplan/erroritself and@suaveplan/types
Scenario: No Node / browser built-ins
- WHEN the built
dist/is grep'd - THEN it MUST NOT reference
node:fs,node:path,node:url,process,Buffer,document,window, or any DOM / Node-only API
Requirement: SuaveplanError base class
The package SHALL export an abstract SuaveplanError class extending the built-in Error, carrying { code, message, cause?, context?, timestamp, traceId? }. code SHALL be a string of the form PACKAGE.DOMAIN.REASON (plain uppercase ASCII, dots as separators). The code field is generic so subclasses can narrow the code union.
Verified by: src/base/suaveplan-error.test.ts and src/base/package-error.test.ts.
Scenario: Subclass narrows code union
- WHEN a consumer defines
class MyError extends SuaveplanError<"MY.X.Y" | "MY.X.Z"> - THEN TypeScript infers
err.codeas"MY.X.Y" | "MY.X.Z"(notstring) insideinstanceof MyErrorbranches
Scenario: Stack trace preserved
- WHEN a
SuaveplanErrorsubclass is thrown - THEN
err.stackcontains the subclass name (notError) AND the originating call site
Scenario: Timestamp is injectable
- WHEN a subclass is constructed with
{ clock: () => 1234 } - THEN
err.timestamp === 1234
Scenario: Default timestamp uses Date.now
- WHEN a subclass is constructed with no
clockoption - THEN
err.timestampequals theDate.now()reading at construction time
Requirement: Stable error-code registry helper
The package SHALL export a defineCodes<TNamespace, TReasons>() helper that returns a frozen Record<TReasons, \${TNamespace}.${TReasons}`>with full literal-type inference. Rebuilding the registry MUST preserve existing codes; removal requires a@deprecated` marker that emits a TypeScript deprecation warning at call sites.
Verified by: src/codes/define-codes.test.ts and src/codes/error-codes.ts.
Scenario: Registry is frozen
- WHEN a consumer attempts
codes.REASON = "X.Y.Z" - THEN the assignment throws in strict mode (object is frozen)
Scenario: Code values are literal-typed
- WHEN a consumer writes
const codes = defineCodes("PKG", ["A", "B"] as const) - THEN
codes.Ahas type"PKG.A"(notstring)
Requirement: Cause-chain traversal is cycle-safe and depth-bounded
The package SHALL export walkCauses(err, { maxDepth?: number }) as a generator yielding each error in the chain. Default maxDepth is 32. Cycles MUST be detected via a WeakSet and terminate iteration without throwing. The same cycle-and-depth discipline SHALL apply to toJSON serialization: when traversal encounters a cycle OR exceeds maxDepth, the cause field is replaced with the TruncatedCause sentinel (see "TruncatedCause sentinel" requirement) rather than throwing or looping.
Verified by: src/causes/walk-causes.test.ts and src/conformance.spec-conformance.test.ts (toJSON cycle + depth-cap scenarios).
Scenario: Linear chain fully traversed
- WHEN
a.cause = b; b.cause = c; c.cause = d - THEN
Array.from(walkCauses(a))returns[a, b, c, d]
Scenario: Cycle terminates iteration
- WHEN
a.cause = b; b.cause = aandwalkCauses(a)is consumed - THEN iteration yields
athenband ends (does not throw, does not loop forever)
Scenario: Depth cap enforced
- WHEN a chain of 100 errors is walked with
{ maxDepth: 10 } - THEN exactly 10 errors are yielded
Scenario: toJSON cycle yields TruncatedCause sentinel
- WHEN
a.cause = b; b.cause = aandtoJSON(a)runs - THEN
result.cause.cause.code === "ERROR.CAUSE.TRUNCATED"AND iteration does not loop forever
Requirement: Lossless wire serialization
The package SHALL export toJSON(err) and fromJSON(value) producing a Zod-validated wire payload. fromJSON returns a ReconstructedError POJO carrying the __suaveplanError: 1 discriminator — NOT a real SuaveplanError instance, since the base class is abstract and consumer-defined subclasses are not on the wire. Round-tripping a SuaveplanError subclass through JSON MUST preserve code, message, context (redactions applied), timestamp, traceId, name, and the cause chain (up to maxDepth). The binary form (toBinary / fromBinary) is a deterministic UTF-8 encoding of the canonicalised JSON form (no external encoder dep); both halves of a round-trip produce structurally identical output.
Verified by: src/serialize/serialize.test.ts and src/conformance.spec-conformance.test.ts (discriminator round-trip scenario).
Scenario: JSON round-trip preserves structural fields and discriminator
- WHEN an error with
{ code, message, context, cause }is serialized viatoJSONand parsed back viafromJSON - THEN the reconstructed value carries
__suaveplanError === 1AND every wire field (code,message,contextpost-redaction,timestamp,traceId,name,cause) is structurally equal to the original AND the result is NOTinstanceof SuaveplanError(intentionally — base class is abstract)
Scenario: Binary round-trip matches JSON round-trip
- WHEN the same error is passed through
toBinary/fromBinary - THEN the resulting error is structurally equal to the JSON round-trip output
Scenario: Zod rejects malformed wire payload
- WHEN
fromJSON({ code: 123 })is called (wrong type) - THEN it throws a
SuaveplanErrorsubclass withcode === "ERROR.WIRE.MALFORMED"ANDcause instanceof ZodError
Requirement: Redaction at the serialization boundary
The package SHALL export markSensitive(value) producing a tagged wrapper. During toJSON / toBinary, tagged values MUST be replaced with the literal string "[REDACTED]" AT ANY DEPTH within err.context — including values nested inside arrays and sub-objects. The redaction walk MUST be cycle-safe (a circular context graph terminates without throwing or looping). In-memory access to the original value via err.context is UNAFFECTED.
Verified by: src/redact/sensitive.test.ts, src/serialize/serialize.test.ts, and src/conformance.spec-conformance.test.ts (deep + cycle-safe redaction scenarios).
Scenario: Sensitive field redacted on the wire
- WHEN
err.context.token = markSensitive("abc")andtoJSON(err)runs - THEN the serialized payload contains
context.token === "[REDACTED]"(string)
Scenario: Nested sensitive value redacted
- WHEN
err.context.user = { profile: { token: markSensitive("abc") } }andtoJSON(err)runs - THEN
result.context.user.profile.token === "[REDACTED]"
Scenario: Sensitive value inside array redacted
- WHEN
err.context.tokens = [markSensitive("a"), markSensitive("b")]andtoJSON(err)runs - THEN
result.context.tokens === ["[REDACTED]", "[REDACTED]"]
Scenario: Cyclic context terminates redaction
- WHEN
err.context.selfis set to a cycle (ctx.self = ctx) andtoJSON(err)runs - THEN redaction completes without throwing or looping (cycle short-circuits via
WeakSet)
Scenario: In-memory value remains accessible
- WHEN
err.context.token = markSensitive("abc")(unserialized) - THEN
unwrapSensitive(err.context.token) === "abc"
Requirement: OTel span event adapter
The package SHALL export toSpanEvent(err) returning { name: "exception", attributes: { "exception.type", "exception.message", "exception.stacktrace", "exception.code", "exception.traceId"? } } with all values already string-serialized. No OpenTelemetry SDK is imported.
Verified by: src/otel/to-span-event.test.ts.
Scenario: Span event matches OTel spec
- WHEN
toSpanEvent(err)is called - THEN the returned
name === "exception"ANDattributes["exception.type"] === err.constructor.nameANDattributes["exception.message"] === err.messageANDattributes["exception.stacktrace"] === err.stack ?? ""ANDattributes["exception.code"] === err.code
Requirement: i18n-ready messages
No user-visible string literal SHALL be embedded in a throw site. Subclasses MUST instead reference an i18n key (of the form error.<package>.<domain>.<reason>) which the default implementation renders by returning the key itself. Consumers can plug in a translator via setMessageResolver(fn).
Verified by: src/i18n/resolver.test.ts and src/base/suaveplan-error.test.ts (resolver-applied scenarios).
Scenario: Default resolver returns the key
- WHEN no resolver has been set and a subclass formats
"error.pkg.domain.reason" - THEN the rendered message equals
"error.pkg.domain.reason"verbatim
Scenario: Custom resolver applied
- WHEN
setMessageResolver((key, params) => translations[key](params))has been set - THEN subclass construction calls the resolver and stores the returned string on
err.message
Requirement: Test coverage at 100%
The package MUST maintain statements: 100, branches: 100, functions: 100, lines: 100 thresholds in vitest.config.ts. No istanbul ignore pragmas are permitted (they do not survive the esbuild transform). Defensive code whose false branch is unreachable MUST be rewritten as an as assertion with an invariant comment rather than a runtime if.
Verified by: vitest.config.ts thresholds and the full package test suite under bunx turbo test:coverage --filter=@suaveplan/error.
Scenario: Coverage thresholds enforced
- WHEN
bunx turbo test:coverage --filter=@suaveplan/errorruns - THEN it exits non-zero if any of statements / branches / functions / lines drops below 100
Scenario: Property-based round-trip
- WHEN fast-check generates arbitrary
SuaveplanErrorsubclass instances - THEN every instance survives
toJSON→fromJSONstructurally equal (modulo redactions) AND every instance survivestoBinary→fromBinarystructurally equal
Requirement: TruncatedCause sentinel
When toJSON encounters either (a) a cause-chain depth that exceeds maxDepth OR (b) an already-seen object (cycle), the cause field SHALL be replaced with a sentinel WireError of shape { __suaveplanError: 1, code: "ERROR.CAUSE.TRUNCATED", name: "TruncatedCause", message: "[cause chain truncated]", timestamp: 0 }. The sentinel is structurally a valid WireError (passes Zod validation) and is therefore safely consumable by fromJSON downstream. The sentinel MUST NOT introduce a real Error throw; truncation is a non-fatal terminal event in the chain.
Verified by: src/conformance.spec-conformance.test.ts (depth + cycle sentinel scenarios) and src/serialize/serialize.test.ts.
Scenario: Depth-cap emits sentinel
- WHEN
toJSON(err, { maxDepth: 1 })runs on an error withcause.cause.causeof depth 3 - THEN the chain at the cap position contains the
TruncatedCausesentinel AND the sentinel'scode === "ERROR.CAUSE.TRUNCATED"
Scenario: Cycle emits sentinel
- WHEN
toJSON(a)runs ona.cause = b; b.cause = a(cycle) - THEN the chain terminates at the cycle with the sentinel AND no infinite loop occurs
Scenario: Sentinel survives round-trip
- WHEN a
toJSONoutput containing aTruncatedCausesentinel is passed back throughfromJSON - THEN the reconstructed value carries the sentinel intact (
code === "ERROR.CAUSE.TRUNCATED",name === "TruncatedCause") without throwingWIRE.MALFORMED
Requirement: Wire format version discriminator
The wire payload SHALL carry a __suaveplanError: 1 discriminator literal validated by Zod's z.literal(1). This identifies the payload as a @suaveplan/error wire envelope and pins the format version. WIRE_VERSION is the single source-of-truth constant in src/constants/index.ts. fromJSON MUST reject payloads where __suaveplanError is missing OR not equal to WIRE_VERSION with code === "ERROR.WIRE.MALFORMED" (Zod-driven). When WIRE_VERSION is bumped (future), payload migration is the consumer's responsibility — there is no implicit version negotiation in this package.
Verified by: src/serialize/wire.schemas.ts (Zod literal), src/serialize/serialize.test.ts, and src/conformance.spec-conformance.test.ts (discriminator scenarios).
Scenario: Discriminator preserved through round-trip
- WHEN an error is passed through
toJSONthenfromJSON - THEN the reconstructed value has
__suaveplanError === 1
Scenario: Missing discriminator rejected
- WHEN
fromJSON({ code: "X.Y.Z", message: "m", timestamp: 0, name: "E" })runs (no__suaveplanError) - THEN it throws with
code === "ERROR.WIRE.MALFORMED"
Scenario: Wrong discriminator rejected
- WHEN
fromJSON({ __suaveplanError: 2, ...rest })runs - THEN it throws with
code === "ERROR.WIRE.MALFORMED"(Zod literal mismatch)