Add libs/errs: ErrCode + CodeError canonical error system

Phase 3 of the convention pass — introduces the framework's single
canonical error system, designed in dialogue with the user and
documented in docs/conventions.md section 4.

Public API:

- `ErrCode` — branded string type for `module::path.with.dots`
  identifiers. Validated at construction via `code(value)` (throws
  `CodeFormatError`) or `isValidCode(value)` predicate.
- Helpers: `moduleOf`, `leaf`, `parent`, `sub`, `parts`,
  `isDescendantOf`, `isEqualOrDescendantOf`, `codeToLangPath`.
- `CodeError` — single error class for the framework. Carries a
  required `ErrCode` (its identity), optional dev-facing `message`,
  optional `cause`, and a frozen `meta` bag of contextual fields.
- Fluent immutable decorators: `withMessage`, `withTime`,
  `withRequestId`, `withTenant`, `withUser`, `withData`, generic
  `with(meta)`. Each returns a new `CodeError` with the same code —
  identity is invariant under decoration.
- `isCodeError(value)` type guard.
- `matches(value, family)` family-membership predicate, the natural
  way to catch `if (matches(err, BUSS_ERR))` for everything in the bus.
- `CodeFormatError` — bootstrap exception (does NOT extend CodeError
  to break the circular construction dependency).

Format properties:

- `module::path.with.dots` — `::` separates module from hierarchy,
  `.` separates segments inside the path, `_` allowed inside a single
  segment as a word separator.
- Lowercase alphanumeric only. No uppercase, hyphens, slashes, spaces.
- Strict descendancy check respects segment boundaries:
  `'buss::listener_extra'` is NOT a descendant of `'buss::listener'`.
- `codeToLangPath` swaps `::` for `.` so the same `ErrCode` can also
  serve as the i18n path: `t(codeToLangPath(err.code))`.

Validation reasons exported as stable string constants
(`ERRS_VALIDATION_REASON_*`) so callers can branch on them
programmatically without pattern-matching error messages.

Tests: 62 in `code.test.ts` + 25 in `code-error.test.ts`. Cover
validation paths, all helpers, the fluent decorator chain, immutability,
toJSON shape, isCodeError discrimination across CodeError subclasses,
and matches() for identity / family / decoration cases.

This commit only adds `libs/errs/` — no existing module migrates yet.
Phase 4 pilots the new system in `arts/sium`.

Verification: svelte-check 1402 files / 0 errors. Server 1292 tests
(+62), client 19 tests — all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent 6b5533c840
commit 38160565b2

@ -0,0 +1,181 @@
import { type ErrCode, isEqualOrDescendantOf, moduleOf } from './code.ts';
/**
* Metadata accumulated on an error as it travels through layers. Each
* `with*` decorator adds one piece of metadata without changing the
* error's identity (the `code`).
*
* All fields are optional — a freshly thrown `CodeError` carries only
* `code` and `message` until upper layers decorate it.
*/
export interface CodeErrorMeta {
/**
* User-facing message. Either a literal string or a `LangRef`
* (e.g. `'#?common.action_failed'`) that the presentation layer
* resolves through `App.Lang.t(...)`. Distinct from `Error.message`,
* which is the developer/log-facing message.
*/
readonly userMessage?: string;
/** Wall-clock timestamp when the error was decorated. */
readonly timestamp?: number;
/** Distributed trace identifier. */
readonly requestId?: string;
/** Tenant under which the error occurred (multi-tenant deployments). */
readonly tenantId?: string;
/** User affected by the error. */
readonly userId?: string;
/** Free-form structured data. Escape hatch for cases not covered above. */
readonly data?: Readonly<Record<string, unknown>>;
}
/**
* Single canonical error class for the framework. Carries an
* `ErrCode` (its identity), an optional dev-facing `message`, an
* optional `cause`, and a `meta` bag of contextual fields populated
* via the fluent `with*` decorators.
*
* Decorators are immutable — each `withX` returns a new `CodeError`
* with the same code and a clone of `meta` plus the new field. The
* `code` never changes through decoration, so the family-match
* predicate (`matches(err, family)`) keeps working at every layer.
*/
export class CodeError extends Error {
readonly code: ErrCode;
readonly meta: CodeErrorMeta;
constructor(
code: ErrCode,
init: {
readonly message?: string;
readonly cause?: unknown;
readonly meta?: CodeErrorMeta;
} = {}
) {
super(init.message ?? code, { cause: init.cause });
this.name = code;
this.code = code;
this.meta = Object.freeze({ ...init.meta });
}
// ── Fluent decorators ─────────────────────────────────────────────
withMessage(userMessage: string): CodeError {
return this.cloneWith({ userMessage });
}
withTime(timestamp: number = Date.now()): CodeError {
return this.cloneWith({ timestamp });
}
withRequestId(requestId: string): CodeError {
return this.cloneWith({ requestId });
}
withTenant(tenantId: string): CodeError {
return this.cloneWith({ tenantId });
}
withUser(userId: string): CodeError {
return this.cloneWith({ userId });
}
withData(data: Readonly<Record<string, unknown>>): CodeError {
return this.cloneWith({ data });
}
/** Generic decorator — add any subset of `CodeErrorMeta`. */
with(meta: Partial<CodeErrorMeta>): CodeError {
return this.cloneWith(meta);
}
// ── Convenience accessors ─────────────────────────────────────────
get module(): string {
return moduleOf(this.code);
}
get userMessage(): string | undefined {
return this.meta.userMessage;
}
get timestamp(): number | undefined {
return this.meta.timestamp;
}
get requestId(): string | undefined {
return this.meta.requestId;
}
get tenantId(): string | undefined {
return this.meta.tenantId;
}
get userId(): string | undefined {
return this.meta.userId;
}
// ── Serialization ─────────────────────────────────────────────────
toJSON(): {
readonly name: string;
readonly code: string;
readonly message: string;
readonly meta: CodeErrorMeta;
} {
return {
name: this.name,
code: this.code,
message: this.message,
meta: this.meta
};
}
// ── Internals ─────────────────────────────────────────────────────
private cloneWith(patch: Partial<CodeErrorMeta>): CodeError {
return new CodeError(this.code, {
message: this.message,
cause: this.cause,
meta: { ...this.meta, ...patch }
});
}
}
/**
* Type guard for `CodeError` (and its subclasses, since `instanceof`
* walks the prototype chain). Useful at error boundaries:
*
* ```ts
* catch (err) {
* if (isCodeError(err)) Logger.error(err.module, err.code, err.meta);
* else throw err;
* }
* ```
*/
export function isCodeError(value: unknown): value is CodeError {
return value instanceof CodeError;
}
/**
* `true` when `value` is a `CodeError` whose `code` is `family` or a
* descendant of `family`. Lets a single check cover entire families:
*
* ```ts
* matches(err, BUSS_ERR_DISPOSED) // exact identity
* matches(err, BUSS_ERR) // any error from the buss module
* matches(err, BUSS_ERR_LISTENER) // any error under the listener sub-tree
* ```
*
* Boundary check (alphanumeric-only segments + `.` separator) prevents
* false positives like `'buss::listener_extra'` matching the family
* `'buss::listener'`.
*/
export function matches(value: unknown, family: ErrCode): boolean {
if (!isCodeError(value)) return false;
return isEqualOrDescendantOf(value.code, family);
}

@ -0,0 +1,254 @@
import {
ERRS_LIMIT_MIN_SEGMENT_LEN,
ERRS_LIMIT_MIN_TOTAL_LEN,
ERRS_SEP_MODULE,
ERRS_SEP_PATH,
ERRS_VALIDATION_REASON_CONSECUTIVE_SEPS,
ERRS_VALIDATION_REASON_DUPLICATE_MODULE_SEP,
ERRS_VALIDATION_REASON_EMPTY_MODULE,
ERRS_VALIDATION_REASON_EMPTY_SEGMENT,
ERRS_VALIDATION_REASON_FIRST_CHAR,
ERRS_VALIDATION_REASON_INCOMPLETE_MODULE_SEP,
ERRS_VALIDATION_REASON_INVALID_CHAR,
ERRS_VALIDATION_REASON_LAST_CHAR,
ERRS_VALIDATION_REASON_LENGTH,
ERRS_VALIDATION_REASON_MISSING_MODULE_SEP,
ERRS_VALIDATION_REASON_PATH_BEFORE_MODULE
} from './consts.ts';
import { CodeFormatError } from './errors.ts';
declare const __errCode: unique symbol;
/**
* Branded string that has been validated as `module::path.with.dots`.
*
* Construction goes through `code(value)` (throws on invalid) or
* `sub(parent, segment)` (composes from an existing `ErrCode`). The
* brand prevents accidentally passing an unvalidated string where an
* `ErrCode` is expected.
*/
export type ErrCode = string & { readonly [__errCode]: true };
// ── Character predicates ──────────────────────────────────────────────
const CODE_LOWER_A = 0x61; // 'a'
const CODE_LOWER_Z = 0x7a; // 'z'
const CODE_DIGIT_0 = 0x30; // '0'
const CODE_DIGIT_9 = 0x39; // '9'
const CODE_UNDERSCORE = 0x5f; // '_'
const CODE_DOT = 0x2e; // '.'
const CODE_COLON = 0x3a; // ':'
function isAlphaNumLower(charCode: number): boolean {
return (
(charCode >= CODE_LOWER_A && charCode <= CODE_LOWER_Z) ||
(charCode >= CODE_DIGIT_0 && charCode <= CODE_DIGIT_9)
);
}
// ── Public API ────────────────────────────────────────────────────────
/**
* Validate `value` and return it as an `ErrCode`. Designed for
* module-level constants — fails fast at load time when a code is
* malformed.
*
* @throws CodeFormatError when `value` does not match the format.
*/
export function code(value: string): ErrCode {
const reason = validateCode(value);
if (reason !== null) throw new CodeFormatError(value, reason);
return value as ErrCode;
}
/**
* Predicate form of `code()`. Returns `true` if the string is a valid
* `ErrCode`. Use this when validating dynamic input that should not
* throw.
*/
export function isValidCode(value: string): value is ErrCode {
return validateCode(value) === null;
}
/**
* Returns a stable reason string for an invalid code, or `null` when
* valid. Reasons are exported as `ERRS_VALIDATION_REASON_*` constants
* so callers can branch on them programmatically.
*/
export function validateCode(value: string): string | null {
const len = value.length;
if (len < ERRS_LIMIT_MIN_TOTAL_LEN) return ERRS_VALIDATION_REASON_LENGTH;
if (!isAlphaNumLower(value.charCodeAt(0))) {
return ERRS_VALIDATION_REASON_FIRST_CHAR;
}
if (!isAlphaNumLower(value.charCodeAt(len - 1))) {
return ERRS_VALIDATION_REASON_LAST_CHAR;
}
let hasModuleSep = false;
let prevWasSep = false;
for (let i = 0; i < len; i += 1) {
const ch = value.charCodeAt(i);
if (isAlphaNumLower(ch)) {
prevWasSep = false;
continue;
}
if (ch === CODE_UNDERSCORE) {
if (prevWasSep) return ERRS_VALIDATION_REASON_CONSECUTIVE_SEPS;
// `_` is valid only between alphanumerics. Set prevWasSep so
// any following separator (`.`, `::`, `_`) is rejected.
prevWasSep = true;
continue;
}
if (ch === CODE_COLON) {
if (hasModuleSep) return ERRS_VALIDATION_REASON_DUPLICATE_MODULE_SEP;
if (i + 1 >= len || value.charCodeAt(i + 1) !== CODE_COLON) {
return ERRS_VALIDATION_REASON_INCOMPLETE_MODULE_SEP;
}
if (prevWasSep) return ERRS_VALIDATION_REASON_CONSECUTIVE_SEPS;
if (i === 0) return ERRS_VALIDATION_REASON_EMPTY_MODULE;
hasModuleSep = true;
prevWasSep = true;
i += 1; // skip second `:`
continue;
}
if (ch === CODE_DOT) {
if (!hasModuleSep) return ERRS_VALIDATION_REASON_PATH_BEFORE_MODULE;
if (prevWasSep) return ERRS_VALIDATION_REASON_CONSECUTIVE_SEPS;
prevWasSep = true;
continue;
}
return ERRS_VALIDATION_REASON_INVALID_CHAR;
}
if (!hasModuleSep) return ERRS_VALIDATION_REASON_MISSING_MODULE_SEP;
return null;
}
/**
* Returns the module portion of a code:
* `'buss::listener.failed'` → `'buss'`.
*/
export function moduleOf(c: ErrCode): string {
const idx = c.indexOf(ERRS_SEP_MODULE);
return c.slice(0, idx);
}
/**
* Returns the most specific segment (the leaf):
* `'buss::listener.failed'` → `'failed'`,
* `'buss::disposed'` → `'disposed'`.
*/
export function leaf(c: ErrCode): string {
const dotIdx = c.lastIndexOf(ERRS_SEP_PATH);
if (dotIdx >= 0) return c.slice(dotIdx + 1);
return c.slice(c.indexOf(ERRS_SEP_MODULE) + ERRS_SEP_MODULE.length);
}
/**
* Returns the parent code by stripping the last segment:
* `'buss::listener.failed'` → `'buss::listener'`,
* `'buss::disposed'` → `undefined` (no parent inside the module).
*/
export function parent(c: ErrCode): ErrCode | undefined {
const dotIdx = c.lastIndexOf(ERRS_SEP_PATH);
if (dotIdx < 0) return undefined;
return c.slice(0, dotIdx) as ErrCode;
}
/**
* Compose a child code by appending a segment to a parent:
* `sub(BUSS, 'disposed')` → `'buss::disposed'`,
* `sub(BUSS_LISTENER, 'failed')` → `'buss::listener.failed'`.
*
* @throws CodeFormatError when `segment` is not a valid segment.
*/
export function sub(parentCode: ErrCode, segment: string): ErrCode {
if (!isValidSegment(segment)) {
throw new CodeFormatError(segment, ERRS_VALIDATION_REASON_EMPTY_SEGMENT);
}
return `${parentCode}${ERRS_SEP_PATH}${segment}` as ErrCode;
}
/**
* Decompose a code into its module and segments:
* `'buss::listener.failed'` → `['buss', 'listener', 'failed']`.
*/
export function parts(c: ErrCode): readonly string[] {
const moduleEnd = c.indexOf(ERRS_SEP_MODULE);
const out: string[] = [c.slice(0, moduleEnd)];
const tail = c.slice(moduleEnd + ERRS_SEP_MODULE.length);
if (tail.length > 0) {
for (const segment of tail.split(ERRS_SEP_PATH)) {
out.push(segment);
}
}
return out;
}
/**
* `c` is a strict descendant of `ancestor` — true only when `c` is a
* deeper code in the same family, never when they are equal.
*
* Detects segment boundaries: `'buss::listener_extra'` is NOT a
* descendant of `'buss::listener'` because `_extra` is part of the
* same segment.
*/
export function isDescendantOf(c: ErrCode, ancestor: ErrCode): boolean {
if (c === ancestor) return false;
return c.startsWith(`${ancestor}${ERRS_SEP_PATH}`);
}
/**
* `c` is equal to or a descendant of `ancestor`. The natural test for
* "this error belongs to the X family" — combines identity and
* descendancy.
*/
export function isEqualOrDescendantOf(c: ErrCode, ancestor: ErrCode): boolean {
if (c === ancestor) return true;
return c.startsWith(`${ancestor}${ERRS_SEP_PATH}`);
}
/**
* Convert an `ErrCode` into a Lang lookup path by replacing the
* module separator `::` with `.`:
* `'buss::listener.failed'` → `'buss.listener.failed'`.
*
* The result is the path used by `lang.t(path, ...)`. Callers that
* need a `LangRef` (`#?path`) prepend the prefix themselves.
*/
export function codeToLangPath(c: ErrCode): string {
return c.replace(ERRS_SEP_MODULE, ERRS_SEP_PATH);
}
// ── Internal helpers ──────────────────────────────────────────────────
function isValidSegment(segment: string): boolean {
const len = segment.length;
if (len < ERRS_LIMIT_MIN_SEGMENT_LEN) return false;
if (!isAlphaNumLower(segment.charCodeAt(0))) return false;
if (!isAlphaNumLower(segment.charCodeAt(len - 1))) return false;
let prevWasUnderscore = false;
for (let i = 0; i < len; i += 1) {
const ch = segment.charCodeAt(i);
if (isAlphaNumLower(ch)) {
prevWasUnderscore = false;
continue;
}
if (ch === CODE_UNDERSCORE) {
if (prevWasUnderscore) return false;
prevWasUnderscore = true;
continue;
}
return false;
}
return true;
}

@ -0,0 +1,51 @@
/**
* Constants for `libs/errs`.
*
* The `errs` module owns the `ErrCode` branded type and the
* `CodeError` class. Per docs/conventions.md, every value here
* follows `<MOD>_<CATEGORY>_<NAME>` with `MOD = ERRS`.
*/
export const ERRS_MODULE = 'errs';
// ── ErrCode format ────────────────────────────────────────────────────
/** Separator between module and hierarchical path: `module::path.with.dots`. */
export const ERRS_SEP_MODULE = '::';
/** Separator between segments of the hierarchical path. */
export const ERRS_SEP_PATH = '.';
/** Internal word separator allowed within a segment (`my_segment`). */
export const ERRS_SEP_WORD = '_';
/** Minimum length of a module or path segment. */
export const ERRS_LIMIT_MIN_SEGMENT_LEN = 1;
/** Minimum total length of an `ErrCode`: `m::e` is the smallest valid value. */
export const ERRS_LIMIT_MIN_TOTAL_LEN = 4;
// ── Error names ───────────────────────────────────────────────────────
export const ERRS_ERROR_NAME_FORMAT = 'CodeFormatError';
// ── Error messages ────────────────────────────────────────────────────
export const ERRS_ERROR_MSG_FORMAT_PREFIX = `[${ERRS_MODULE}] invalid code`;
// ── Validation reasons (returned by `validateCode`) ───────────────────
//
// Stable strings so consumers can branch on them in tests without
// pattern-matching the human-readable wording.
export const ERRS_VALIDATION_REASON_LENGTH = 'length';
export const ERRS_VALIDATION_REASON_FIRST_CHAR = 'first_char';
export const ERRS_VALIDATION_REASON_LAST_CHAR = 'last_char';
export const ERRS_VALIDATION_REASON_INVALID_CHAR = 'invalid_char';
export const ERRS_VALIDATION_REASON_MISSING_MODULE_SEP = 'missing_module_sep';
export const ERRS_VALIDATION_REASON_DUPLICATE_MODULE_SEP = 'duplicate_module_sep';
export const ERRS_VALIDATION_REASON_INCOMPLETE_MODULE_SEP = 'incomplete_module_sep';
export const ERRS_VALIDATION_REASON_PATH_BEFORE_MODULE = 'path_before_module';
export const ERRS_VALIDATION_REASON_CONSECUTIVE_SEPS = 'consecutive_seps';
export const ERRS_VALIDATION_REASON_EMPTY_MODULE = 'empty_module';
export const ERRS_VALIDATION_REASON_EMPTY_SEGMENT = 'empty_segment';

@ -0,0 +1,30 @@
import {
ERRS_ERROR_MSG_FORMAT_PREFIX,
ERRS_ERROR_NAME_FORMAT
} from './consts.ts';
/**
* Bootstrap exception for `libs/errs`. Thrown by `code(value)` when
* `value` does not parse as a valid `ErrCode`.
*
* Does NOT extend `CodeError` because constructing a `CodeError`
* requires a valid `ErrCode`, and producing one would call back into
* `code()` — circular at module-load time. This is the single error
* in the codebase that uses the legacy plain-`Error` shape, on
* purpose.
*/
export class CodeFormatError extends Error {
readonly name = ERRS_ERROR_NAME_FORMAT;
readonly invalidValue: string;
readonly reason: string;
constructor(invalidValue: string, reason: string) {
super(`${ERRS_ERROR_MSG_FORMAT_PREFIX} "${invalidValue}": ${reason}`);
this.invalidValue = invalidValue;
this.reason = reason;
}
}
export function isCodeFormatError(value: unknown): value is CodeFormatError {
return value instanceof CodeFormatError;
}

@ -0,0 +1,52 @@
/**
* `libs/errs` — canonical `ErrCode` and `CodeError` for the framework.
*
* Every artifact's error catalog (constants of type `ErrCode`) and
* every thrown `CodeError` derive from this module. Single source of
* truth for error identity, error messages (via `<MOD>_MODULE` template
* prefix), and the i18n lookup key (via `codeToLangPath`).
*
* See `docs/conventions.md` section 4 for the constant naming rules.
*/
export {
ERRS_ERROR_MSG_FORMAT_PREFIX,
ERRS_ERROR_NAME_FORMAT,
ERRS_LIMIT_MIN_SEGMENT_LEN,
ERRS_LIMIT_MIN_TOTAL_LEN,
ERRS_MODULE,
ERRS_SEP_MODULE,
ERRS_SEP_PATH,
ERRS_SEP_WORD,
ERRS_VALIDATION_REASON_CONSECUTIVE_SEPS,
ERRS_VALIDATION_REASON_DUPLICATE_MODULE_SEP,
ERRS_VALIDATION_REASON_EMPTY_MODULE,
ERRS_VALIDATION_REASON_EMPTY_SEGMENT,
ERRS_VALIDATION_REASON_FIRST_CHAR,
ERRS_VALIDATION_REASON_INCOMPLETE_MODULE_SEP,
ERRS_VALIDATION_REASON_INVALID_CHAR,
ERRS_VALIDATION_REASON_LAST_CHAR,
ERRS_VALIDATION_REASON_LENGTH,
ERRS_VALIDATION_REASON_MISSING_MODULE_SEP,
ERRS_VALIDATION_REASON_PATH_BEFORE_MODULE
} from './consts.ts';
export { CodeFormatError, isCodeFormatError } from './errors.ts';
export {
code,
codeToLangPath,
isDescendantOf,
isEqualOrDescendantOf,
isValidCode,
leaf,
moduleOf,
parent,
parts,
sub,
validateCode
} from './code.ts';
export type { ErrCode } from './code.ts';
export { CodeError, isCodeError, matches } from './code-error.ts';
export type { CodeErrorMeta } from './code-error.ts';

@ -0,0 +1,205 @@
import { describe, expect, it } from 'vitest';
import {
code,
CodeError,
isCodeError,
matches
} from '../index.ts';
const MOD_BASE = code('buss::base');
const MOD_DISPOSED = code('buss::disposed');
const MOD_LISTENER = code('buss::listener');
const MOD_LISTENER_FAILED = code('buss::listener.failed');
const MOD_OTHER = code('sess::expired');
describe('CodeError — construction', () => {
it('uses the code as the error name', () => {
const err = new CodeError(MOD_DISPOSED);
expect(err.name).toBe(MOD_DISPOSED);
expect(err.code).toBe(MOD_DISPOSED);
});
it('uses the code as the message when none is provided', () => {
const err = new CodeError(MOD_DISPOSED);
expect(err.message).toBe(MOD_DISPOSED);
});
it('accepts an explicit dev-facing message', () => {
const err = new CodeError(MOD_DISPOSED, { message: 'bus closed' });
expect(err.message).toBe('bus closed');
expect(err.code).toBe(MOD_DISPOSED);
});
it('accepts a cause', () => {
const cause = new Error('inner');
const err = new CodeError(MOD_DISPOSED, { cause });
expect(err.cause).toBe(cause);
});
it('accepts initial meta and freezes it', () => {
const err = new CodeError(MOD_DISPOSED, {
meta: { userMessage: '#?common.failed', timestamp: 42 }
});
expect(err.meta.userMessage).toBe('#?common.failed');
expect(err.meta.timestamp).toBe(42);
expect(Object.isFrozen(err.meta)).toBe(true);
});
it('exposes the module via the getter', () => {
const err = new CodeError(MOD_LISTENER_FAILED);
expect(err.module).toBe('buss');
});
});
describe('CodeError — fluent decorators', () => {
it('withMessage returns a new instance with the userMessage set', () => {
const original = new CodeError(MOD_DISPOSED);
const decorated = original.withMessage('User-facing text');
expect(decorated).not.toBe(original);
expect(decorated.userMessage).toBe('User-facing text');
expect(original.userMessage).toBeUndefined();
});
it('withTime defaults to Date.now()', () => {
const before = Date.now();
const err = new CodeError(MOD_DISPOSED).withTime();
const after = Date.now();
expect(err.timestamp).toBeGreaterThanOrEqual(before);
expect(err.timestamp).toBeLessThanOrEqual(after);
});
it('withTime accepts an explicit timestamp', () => {
const err = new CodeError(MOD_DISPOSED).withTime(999);
expect(err.timestamp).toBe(999);
});
it('withRequestId, withTenant, withUser populate their fields', () => {
const err = new CodeError(MOD_DISPOSED)
.withRequestId('req-1')
.withTenant('tenant-1')
.withUser('user-1');
expect(err.requestId).toBe('req-1');
expect(err.tenantId).toBe('tenant-1');
expect(err.userId).toBe('user-1');
});
it('withData attaches free-form data', () => {
const err = new CodeError(MOD_DISPOSED).withData({ retries: 3 });
expect(err.meta.data).toEqual({ retries: 3 });
});
it('chains decorators preserving the code', () => {
const err = new CodeError(MOD_DISPOSED)
.withMessage('hi')
.withTime(123)
.withRequestId('r1');
expect(err.code).toBe(MOD_DISPOSED);
expect(err.userMessage).toBe('hi');
expect(err.timestamp).toBe(123);
expect(err.requestId).toBe('r1');
});
it('with() accepts any subset of meta', () => {
const err = new CodeError(MOD_DISPOSED).with({
userMessage: 'x',
tenantId: 'y'
});
expect(err.userMessage).toBe('x');
expect(err.tenantId).toBe('y');
});
it('decorators do not mutate the original', () => {
const original = new CodeError(MOD_DISPOSED);
original.withMessage('a').withTime(1);
expect(original.userMessage).toBeUndefined();
expect(original.timestamp).toBeUndefined();
});
it('preserves message and cause across decoration', () => {
const cause = new Error('inner');
const err = new CodeError(MOD_DISPOSED, { message: 'tech', cause }).withMessage(
'user'
);
expect(err.message).toBe('tech');
expect(err.cause).toBe(cause);
expect(err.userMessage).toBe('user');
});
});
describe('toJSON()', () => {
it('emits name, code, message and meta', () => {
const err = new CodeError(MOD_DISPOSED, { message: 'bus closed' })
.withRequestId('r1')
.withTime(42);
expect(err.toJSON()).toEqual({
name: MOD_DISPOSED,
code: MOD_DISPOSED,
message: 'bus closed',
meta: { requestId: 'r1', timestamp: 42 }
});
});
});
describe('isCodeError()', () => {
it('returns true for a CodeError', () => {
expect(isCodeError(new CodeError(MOD_DISPOSED))).toBe(true);
});
it('returns false for plain errors', () => {
expect(isCodeError(new Error('x'))).toBe(false);
expect(isCodeError(new TypeError('x'))).toBe(false);
});
it('returns false for non-error values', () => {
expect(isCodeError(null)).toBe(false);
expect(isCodeError(undefined)).toBe(false);
expect(isCodeError({})).toBe(false);
expect(isCodeError('error')).toBe(false);
});
it('returns true for subclasses of CodeError', () => {
class BussReentrancyError extends CodeError {
readonly depth: number;
constructor(depth: number) {
super(MOD_DISPOSED);
this.depth = depth;
}
}
expect(isCodeError(new BussReentrancyError(5))).toBe(true);
});
});
describe('matches()', () => {
it('returns true on exact code identity', () => {
const err = new CodeError(MOD_DISPOSED);
expect(matches(err, MOD_DISPOSED)).toBe(true);
});
it('returns true for descendants of a family root', () => {
const err = new CodeError(MOD_LISTENER_FAILED);
expect(matches(err, MOD_LISTENER)).toBe(true);
expect(matches(err, MOD_BASE)).toBe(false);
});
it('returns true when the code IS the family', () => {
const err = new CodeError(MOD_LISTENER);
expect(matches(err, MOD_LISTENER)).toBe(true);
});
it('returns false for unrelated codes', () => {
const err = new CodeError(MOD_DISPOSED);
expect(matches(err, MOD_OTHER)).toBe(false);
});
it('returns false for non-CodeError values', () => {
expect(matches(new Error('x'), MOD_DISPOSED)).toBe(false);
expect(matches(null, MOD_DISPOSED)).toBe(false);
expect(matches(undefined, MOD_DISPOSED)).toBe(false);
});
it('preserves family match after decoration', () => {
const err = new CodeError(MOD_LISTENER_FAILED).withMessage('x').withTime(1);
expect(matches(err, MOD_LISTENER)).toBe(true);
expect(matches(err, MOD_LISTENER_FAILED)).toBe(true);
});
});

@ -0,0 +1,232 @@
import { describe, expect, it } from 'vitest';
import {
code,
codeToLangPath,
isDescendantOf,
isEqualOrDescendantOf,
isValidCode,
leaf,
moduleOf,
parent,
parts,
sub,
validateCode,
type ErrCode
} from '../index.ts';
import {
CodeFormatError,
ERRS_VALIDATION_REASON_CONSECUTIVE_SEPS,
ERRS_VALIDATION_REASON_DUPLICATE_MODULE_SEP,
ERRS_VALIDATION_REASON_FIRST_CHAR,
ERRS_VALIDATION_REASON_INCOMPLETE_MODULE_SEP,
ERRS_VALIDATION_REASON_INVALID_CHAR,
ERRS_VALIDATION_REASON_LAST_CHAR,
ERRS_VALIDATION_REASON_LENGTH,
ERRS_VALIDATION_REASON_MISSING_MODULE_SEP,
ERRS_VALIDATION_REASON_PATH_BEFORE_MODULE
} from '../index.ts';
describe('code() — validation', () => {
it('accepts the minimum valid code', () => {
expect(code('m::e')).toBe('m::e');
});
it('accepts a flat module + path', () => {
expect(code('auth::login')).toBe('auth::login');
});
it('accepts a deep hierarchy', () => {
expect(code('auth::user.login.failed')).toBe('auth::user.login.failed');
});
it('accepts segments with internal underscores', () => {
expect(code('app::not_found')).toBe('app::not_found');
expect(code('app::section_one.sub_two')).toBe('app::section_one.sub_two');
});
it('accepts digits in segments', () => {
expect(code('a1::b2.c3_d4')).toBe('a1::b2.c3_d4');
});
it('rejects values shorter than the minimum length', () => {
expect(() => code('')).toThrow(CodeFormatError);
expect(() => code('m')).toThrow(CodeFormatError);
expect(() => code('m::')).toThrow(CodeFormatError);
});
it('rejects empty module', () => {
expect(() => code('::e')).toThrow(CodeFormatError);
});
it('rejects missing module separator', () => {
expect(validateCode('login.failed')).toBe(ERRS_VALIDATION_REASON_PATH_BEFORE_MODULE);
expect(validateCode('loginfailedz')).toBe(ERRS_VALIDATION_REASON_MISSING_MODULE_SEP);
});
it('rejects duplicate module separators', () => {
expect(validateCode('a::b::c')).toBe(ERRS_VALIDATION_REASON_DUPLICATE_MODULE_SEP);
});
it('rejects single-colon as incomplete module separator', () => {
expect(validateCode('a:b.c')).toBe(ERRS_VALIDATION_REASON_INCOMPLETE_MODULE_SEP);
});
it('rejects path separator before module separator', () => {
expect(validateCode('a.b::c')).toBe(ERRS_VALIDATION_REASON_PATH_BEFORE_MODULE);
});
it('rejects consecutive separators', () => {
expect(validateCode('a::b..c')).toBe(ERRS_VALIDATION_REASON_CONSECUTIVE_SEPS);
expect(validateCode('a::b._c')).toBe(ERRS_VALIDATION_REASON_CONSECUTIVE_SEPS);
expect(validateCode('a::_b')).toBe(ERRS_VALIDATION_REASON_CONSECUTIVE_SEPS);
expect(validateCode('a::b__c')).toBe(ERRS_VALIDATION_REASON_CONSECUTIVE_SEPS);
});
it('rejects uppercase characters', () => {
expect(validateCode('A::e')).toBe(ERRS_VALIDATION_REASON_FIRST_CHAR);
expect(validateCode('a::bCd')).toBe(ERRS_VALIDATION_REASON_INVALID_CHAR);
});
it('rejects hyphens and slashes', () => {
expect(validateCode('a::b-c')).toBe(ERRS_VALIDATION_REASON_INVALID_CHAR);
expect(validateCode('a::b/c')).toBe(ERRS_VALIDATION_REASON_INVALID_CHAR);
});
it('rejects values starting or ending with a separator', () => {
expect(validateCode('.a::b')).toBe(ERRS_VALIDATION_REASON_FIRST_CHAR);
expect(validateCode('a::b.')).toBe(ERRS_VALIDATION_REASON_LAST_CHAR);
expect(validateCode('_a::b')).toBe(ERRS_VALIDATION_REASON_FIRST_CHAR);
expect(validateCode('a::b_')).toBe(ERRS_VALIDATION_REASON_LAST_CHAR);
});
it('rejects too short with the LENGTH reason', () => {
expect(validateCode('m::')).toBe(ERRS_VALIDATION_REASON_LENGTH);
});
it('CodeFormatError carries the invalid value and reason', () => {
try {
code('A::e');
expect.fail('expected throw');
} catch (err) {
expect(err).toBeInstanceOf(CodeFormatError);
expect((err as CodeFormatError).invalidValue).toBe('A::e');
expect((err as CodeFormatError).reason).toBe(ERRS_VALIDATION_REASON_FIRST_CHAR);
}
});
});
describe('isValidCode()', () => {
it('returns true for valid codes', () => {
expect(isValidCode('auth::login')).toBe(true);
});
it('returns false for invalid codes (no throw)', () => {
expect(isValidCode('A::e')).toBe(false);
expect(isValidCode('')).toBe(false);
});
});
describe('moduleOf()', () => {
it('returns the module portion before "::"', () => {
expect(moduleOf(code('auth::login.failed'))).toBe('auth');
expect(moduleOf(code('m::e'))).toBe('m');
});
});
describe('leaf()', () => {
it('returns the last segment', () => {
expect(leaf(code('auth::user.login.failed'))).toBe('failed');
});
it('returns the first segment after "::" when there is no path', () => {
expect(leaf(code('auth::failed'))).toBe('failed');
});
});
describe('parent()', () => {
it('returns the parent code by stripping the last segment', () => {
expect(parent(code('a::b.c.d'))).toBe('a::b.c');
});
it('returns undefined when the code has no path', () => {
expect(parent(code('a::b'))).toBeUndefined();
});
});
describe('sub()', () => {
it('appends a segment to a parent code', () => {
expect(sub(code('a::b'), 'c')).toBe('a::b.c');
expect(sub(code('a::b.c'), 'd')).toBe('a::b.c.d');
});
it('throws CodeFormatError on invalid segment', () => {
const root = code('a::b');
expect(() => sub(root, '')).toThrow(CodeFormatError);
expect(() => sub(root, 'C')).toThrow(CodeFormatError);
expect(() => sub(root, 'c-d')).toThrow(CodeFormatError);
expect(() => sub(root, '_c')).toThrow(CodeFormatError);
expect(() => sub(root, 'c_')).toThrow(CodeFormatError);
});
});
describe('parts()', () => {
it('splits into module + segments', () => {
expect(parts(code('auth::user.login.failed'))).toEqual([
'auth',
'user',
'login',
'failed'
]);
expect(parts(code('m::e'))).toEqual(['m', 'e']);
});
});
describe('isDescendantOf()', () => {
it('returns true for a strict descendant', () => {
expect(isDescendantOf(code('a::b.c'), code('a::b'))).toBe(true);
expect(isDescendantOf(code('a::b.c.d'), code('a::b'))).toBe(true);
});
it('returns false for identity', () => {
expect(isDescendantOf(code('a::b'), code('a::b'))).toBe(false);
});
it('returns false for an unrelated code', () => {
expect(isDescendantOf(code('a::b'), code('a::c'))).toBe(false);
});
it('respects segment boundaries (no false positives on shared prefix)', () => {
// 'a::b_extra' is NOT a descendant of 'a::b' — the underscore is
// inside the same segment.
const child = code('a::b_extra');
const ancestor = code('a::b');
expect(isDescendantOf(child, ancestor)).toBe(false);
});
});
describe('isEqualOrDescendantOf()', () => {
it('returns true for identity', () => {
expect(isEqualOrDescendantOf(code('a::b'), code('a::b'))).toBe(true);
});
it('returns true for a descendant', () => {
expect(isEqualOrDescendantOf(code('a::b.c'), code('a::b'))).toBe(true);
});
it('returns false for an unrelated code', () => {
expect(isEqualOrDescendantOf(code('a::b'), code('a::c'))).toBe(false);
});
});
describe('codeToLangPath()', () => {
it('replaces the module separator with a dot', () => {
expect(codeToLangPath(code('buss::listener.failed'))).toBe('buss.listener.failed');
expect(codeToLangPath(code('m::e'))).toBe('m.e');
});
it('only replaces the module separator (not subsequent dots)', () => {
const c: ErrCode = code('a::b.c');
expect(codeToLangPath(c)).toBe('a.b.c');
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.