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
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…
Reference in new issue