You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
196 lines
5.6 KiB
196 lines
5.6 KiB
import {
|
|
type ErrCode,
|
|
isEqualOrDescendantOf,
|
|
isModuleSeed,
|
|
moduleOf,
|
|
type ModuleSeed
|
|
} 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` matches `family`.
|
|
* `family` can be:
|
|
*
|
|
* - A `ModuleSeed` (e.g. `BUSS_ERR = 'buss::'`) — matches any code
|
|
* declared by that module.
|
|
* - An `ErrCode` — matches the code itself or any hierarchical
|
|
* descendant within the same module.
|
|
*
|
|
* ```ts
|
|
* matches(err, BUSS_ERR_DISPOSED) // exact code identity
|
|
* matches(err, BUSS_ERR_LISTENER) // any descendant of buss::listener
|
|
* matches(err, BUSS_ERR) // any error declared by buss (the seed)
|
|
* ```
|
|
*
|
|
* Boundary check on `ErrCode` (alphanumeric-only segments + `.`
|
|
* separator) prevents false positives like `'buss::listener_extra'`
|
|
* matching the family `'buss::listener'`.
|
|
*/
|
|
export function matches(value: unknown, family: ErrCode | ModuleSeed): boolean {
|
|
if (!isCodeError(value)) return false;
|
|
if (isModuleSeed(family)) {
|
|
return value.code.startsWith(family);
|
|
}
|
|
return isEqualOrDescendantOf(value.code, family);
|
|
}
|