Add libs/errs README documenting the canonical error system

Captures the conventions that landed across the audit-1-5 migration:
ErrCode and ModuleSeed shapes, the `errCode(parent, segment)` builder,
the per-module recipe (consts.ts + errors.ts + guards), family
matching with `matches(err, family)`, i18n derivation via
`codeToLangPath()`, the immutable decorator API, wire-safe
projections, and the don'ts (no string-concat construction, no
parallel `<MOD>_ERROR_CODES` legacy enums, no `instanceof Error`).

Closes the last open item from the errs migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent 97d8b605b9
commit dace6d00d1

@ -0,0 +1,268 @@
# errs
Canonical error system for the framework. Every error class across
`arts/`, `libs/` and `svrs/` extends `CodeError`; every error catalog
declares its codes through `errCode()` rooted at a `ModuleSeed`. One
identity per error, one i18n path per identity, one family-match
predicate that walks the hierarchy.
```ts
import {
CodeError,
codeToLangPath,
errCode,
matches,
moduleSeed,
type ErrCode,
type ModuleSeed
} from '$libs/errs';
```
## Why a single error system
Before `libs/errs` each artifact invented its own scheme: literal
`name = 'XError'` strings, parallel `<MOD>_ERROR_CODES` enums,
`messageKey` tables for i18n, ad-hoc `is*Error` helpers. Three
problems followed:
- **Discrimination drifted.** Some guards used `instanceof`, others
compared `err.name === '…'`; both worked individually but caller code
needed one or the other.
- **i18n keys diverged from codes.** `error.code === 'AUTH_FOO'` paired
with `error.messageKey === 'auth.error.foo'` was two strings to keep
in sync.
- **Family checks were impossible.** Asking "is this any error from
`cach`?" or "any descendant of `cach::query`?" meant scanning a
hand-maintained list of subclasses.
`libs/errs` fixes all three: the `code` is the identity, the i18n key
is `codeToLangPath(code)`, and `matches(err, family)` walks the
hierarchy by string boundary.
## Concepts
### ErrCode — `'<module>::<segment>(.<segment>)*'`
Every error code is a branded string `'<module>::<path>'` where
`<module>` is the artifact alias (`'auth'`, `'cach'`, …) and `<path>`
is one or more `.`-separated segments:
```ts
'buss::disposed'
'cach::query'
'cach::query.failed'
'auth::session.revoked'
```
The validator allows lowercase alphanumerics + internal underscores
per segment; consecutive separators, leading underscores and capital
letters are rejected at construction time.
### ModuleSeed — `'<module>::'`
`moduleSeed('auth')` produces the typed seed `'auth::'`. It is the
root of every code in that artifact and the family argument used to
match "any error declared by this artifact":
```ts
const AUTH_ERR: ModuleSeed = moduleSeed('auth');
matches(err, AUTH_ERR); // true for every auth code
```
### `errCode(parent, segment)` — the only builder
Building a code by hand-concatenation is forbidden. Use `errCode()`:
```ts
const BUSS_ERR = moduleSeed('buss'); // 'buss::'
const BUSS_ERR_DISPOSED = errCode(BUSS_ERR, 'disposed'); // 'buss::disposed'
const BUSS_ERR_LISTENER = errCode(BUSS_ERR, 'listener'); // 'buss::listener'
const BUSS_ERR_LISTENER_FAILED = errCode(BUSS_ERR_LISTENER, 'failed'); // 'buss::listener.failed'
```
The builder picks the right separator: `::` after a `ModuleSeed`,
`.` after another `ErrCode`. Each `segment` argument is a single
identifier; nested paths are built by chaining calls.
### CodeError — the base class
```ts
class CodeError extends Error {
readonly code: ErrCode; // identity, also assigned to .name
readonly meta: CodeErrorMeta; // optional decoration bag
// ...fluent decorators below
}
```
Every artifact's concrete error extends `CodeError` (directly or via
a thin module-level base like `AuthError`):
```ts
import { CodeError } from '$libs/errs';
import { CACH_ERR_QUERY_FAILED } from './consts';
export class CachQueryFailedError extends CodeError {
constructor(message: string, cause?: unknown) {
super(CACH_ERR_QUERY_FAILED, { message, cause });
}
}
```
`name` is set from `code` automatically, so name-based discrimination
(`err.name === CACH_ERR_QUERY_FAILED`) keeps working across worker
boundaries even though `instanceof` would not.
## Module recipe
Every artifact follows the same shape:
**`<artifact>/consts.ts`**
```ts
import { errCode, moduleSeed, type ErrCode, type ModuleSeed } from '$libs/errs';
export const CACH_MODULE = 'cach';
export const CACH_ERR: ModuleSeed = moduleSeed(CACH_MODULE);
export const CACH_ERR_DISPOSED: ErrCode = errCode(CACH_ERR, 'disposed');
export const CACH_ERR_QUERY: ErrCode = errCode(CACH_ERR, 'query');
export const CACH_ERR_QUERY_FAILED: ErrCode = errCode(CACH_ERR_QUERY, 'failed');
```
**`<artifact>/errors.ts`**
```ts
import { CodeError } from '$libs/errs';
import {
CACH_ERR_DISPOSED,
CACH_ERR_QUERY_FAILED
} from './consts';
export class CachDisposedError extends CodeError {
constructor(message = 'cache disposed') {
super(CACH_ERR_DISPOSED, { message });
}
}
export class CachQueryFailedError extends CodeError {
constructor(message: string, cause?: unknown) {
super(CACH_ERR_QUERY_FAILED, { message, cause });
}
}
export function isCachDisposedError(value: unknown): value is CachDisposedError {
return value instanceof CachDisposedError;
}
export function isCachQueryFailedError(value: unknown): value is CachQueryFailedError {
return value instanceof CachQueryFailedError;
}
```
Conventions:
- One class per code; one guard per class.
- Constructor signature carries the structured fields the call site
needs (cause, ids, statuses…). Don't accept loose `unknown`.
- Constants follow `<MOD>_ERR_<SEGMENT>` (uppercase). The seed is
`<MOD>_ERR`. See `docs/conventions.md` for the full naming rules.
## Family matching
`matches(value, family)` answers "is this error in the family?" without
maintaining a manual list:
```ts
import { matches } from '$libs/errs';
import { CACH_ERR, CACH_ERR_QUERY, CACH_ERR_QUERY_FAILED } from '$cach';
matches(err, CACH_ERR_QUERY_FAILED); // exact identity
matches(err, CACH_ERR_QUERY); // any descendant of cach::query
matches(err, CACH_ERR); // any error declared by cach
```
`family` may be either an `ErrCode` (matches the code or any
descendant in the same module) or a `ModuleSeed` (matches every code
declared by that module). The boundary check on `ErrCode` (alphanumeric
segments + `.` separator) prevents false positives like
`'cach::query_extra'` matching the family `'cach::query'`.
## i18n derivation
The lang path used by the UI is `codeToLangPath(error.code)`:
```ts
codeToLangPath('auth::credential_invalid'); // 'auth.credential_invalid'
codeToLangPath('cach::query.failed'); // 'cach.query.failed'
```
There is no separate `messageKey` table. Translation files mirror the
code structure under each artifact's i18n root, and a missing
translation falls back to the dev message via the lang artifact's
normal resolution.
```svelte
{#if error}
<p>{Lang.t(codeToLangPath(error.code))}</p>
{/if}
```
## Decorators (CodeErrorMeta)
`CodeError` carries an optional `meta` bag for context that travels
with the error:
```ts
err
.withRequestId(ctx.requestId)
.withTenant(tenantId)
.withUser(actorId)
.withTime()
.withData({ resource, attempt });
```
Decorators are immutable — each `with*` returns a new `CodeError` with
the same code and a clone of `meta`. The code never changes through
decoration, so `matches(err, family)` keeps working at every layer.
`withMessage(userMessage)` stores a presentation-layer message
(typically a `LangRef` like `'#?common.action_failed'`). Distinct from
`Error.message`, which stays as the dev/log-facing message.
## Wire-safe projections
When an error crosses a trust boundary (server → client, worker →
main thread, …) project it down to the safe shape:
```ts
function toSafeError(value: unknown): { readonly code: ErrCode } {
if (isCodeError(value)) return { code: value.code };
return { code: ADAPTER_FAILED }; // module-specific fallback
}
```
`code` alone is enough to identify the error class on the receiving
side and derive the i18n path. `cause`, `meta` and the dev `message`
never cross the wire.
## Relation to logr / lang / aapp
- **logr** logs every `CodeError` by `code` (the `name`) and surfaces
`meta.requestId`, `meta.tenantId`, `meta.userId` as fields. The logger
itself never imports from `errs` — it just reads the standard fields.
- **lang** consumes `codeToLangPath(code)` to look up the translation.
Lang files are organized under each module's root (`auth.*`,
`cach.*`, …) so the path is always resolvable.
- **aapp** is the composition root; it wires the error boundary
helpers (`toSafeError`, the structured logger, the lang fallback)
but defines no codes of its own outside `aapp::*`.
## Don't
- Don't compare `error.message` strings — they are dev-facing and
may change.
- Don't introduce parallel `<MOD>_ERROR_CODES` legacy strings — every
identity is an `ErrCode`.
- Don't extend `Error` directly. If a class doesn't fit `CodeError`,
reach for `AppendCause` or another decorator before forking.
- Don't cross-reference codes between artifacts in a string literal.
Import the constant.
- Don't construct `ErrCode` by string concatenation — use `errCode()`
so the validator catches typos at build time.
Loading…
Cancel
Save

Powered by TurnKey Linux.