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