diff --git a/docs/conventions.md b/docs/conventions.md index 3ee63b0..58d1fca 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -129,25 +129,46 @@ When in doubt, propose the addition here before introducing it. ## 4. Error codes (`ErrCode`) — special case under rule 2 -Error codes from `$libs/errs` follow rule 2 with category `ERR`: +Error codes from `$libs/errs` follow rule 2 with category `ERR`. Each +module declares one `_ERR` seed and builds the individual codes +from it via `errCode(parent, segment)` so the module string never +appears as a literal in error declarations: ```ts -import { code, type ErrCode } from '$libs/errs'; +import { errCode, moduleSeed, type ErrCode, type ModuleSeed } from '$libs/errs'; -export const BUSS_ERR: ErrCode = code('buss::base'); // family root -export const BUSS_ERR_DISPOSED: ErrCode = code('buss::disposed'); -export const BUSS_ERR_INVALID_PAYLOAD: ErrCode = code('buss::invalid_payload'); -export const BUSS_ERR_LISTENER: ErrCode = code('buss::listener'); // sub-family root -export const BUSS_ERR_LISTENER_FAILED: ErrCode = code('buss::listener.failed'); +export const BUSS_ERR: ModuleSeed = moduleSeed('buss'); // 'buss::' +export const BUSS_ERR_DISPOSED: ErrCode = errCode(BUSS_ERR, 'disposed'); // 'buss::disposed' +export const BUSS_ERR_INVALID_PAYLOAD: ErrCode = errCode(BUSS_ERR, 'invalid_payload'); +export const BUSS_ERR_LISTENER: ErrCode = errCode(BUSS_ERR, 'listener'); // 'buss::listener' +export const BUSS_ERR_LISTENER_FAILED: ErrCode = errCode(BUSS_ERR_LISTENER, 'failed'); // 'buss::listener.failed' ``` -The string value's path segments mirror the constant's `` part: -`BUSS_ERR_LISTENER_FAILED` ↔ `'buss::listener.failed'`. +The constant's `` part mirrors the path segments inside the +runtime value: `BUSS_ERR_LISTENER_FAILED` ↔ `'buss::listener.failed'`. +The module name lives in exactly one place: `moduleSeed('buss')`. -The `::` separator inside the string distinguishes module from -hierarchy. The constant uses `_` as the syntactic separator (rule 2). -A helper `codeToLangPath(c)` converts `'buss::listener.failed'` → -`'buss.listener.failed'` for the i18n path. +The `::` separator distinguishes module from hierarchy. `errCode` +picks the right separator automatically — `::` after a seed, `.` +between segments. A helper `codeToLangPath(c)` converts +`'buss::listener.failed'` → `'buss.listener.failed'` for the i18n +path. + +### Family matching + +A single predicate `matches(err, family)` from `$libs/errs` covers +both common cases. `family` can be: + +- A **module seed** (`BUSS_ERR`) — matches any error from that + module regardless of hierarchy. +- An **`ErrCode`** — matches the code itself or any hierarchical + descendant within the same module. + +```ts +matches(err, BUSS_ERR_DISPOSED) // exact code +matches(err, BUSS_ERR_LISTENER) // any descendant of buss::listener +matches(err, BUSS_ERR) // any error declared by buss +``` --- diff --git a/src/arts/sium/consts.ts b/src/arts/sium/consts.ts index 352d4a7..319b26e 100644 --- a/src/arts/sium/consts.ts +++ b/src/arts/sium/consts.ts @@ -1,3 +1,5 @@ +import { errCode, moduleSeed, type ErrCode, type ModuleSeed } from '$libs/errs'; + export const SIUM_MODULE = 'sium'; export const SIUM_STANDARD_VENDOR = 'sium'; @@ -5,3 +7,13 @@ export const SIUM_DIAGNOSTIC_EVENTS = { VALIDATION_FAILED: 'sium.validation_failed', RESOLVE_FALLBACK: 'sium.resolve_fallback' } as const; + +// ── Error codes ──────────────────────────────────────────────────────── +// +// Single source of truth for sium error identity. Used both as the +// `name` and `code` of every `CodeError` thrown by sium, and as the +// translation key (via `codeToLangPath`). + +export const SIUM_ERR: ModuleSeed = moduleSeed(SIUM_MODULE); +export const SIUM_ERR_VALIDATION: ErrCode = errCode(SIUM_ERR, 'validation'); +export const SIUM_ERR_ASYNC_SCHEMA: ErrCode = errCode(SIUM_ERR, 'async_schema'); diff --git a/src/arts/sium/core/index.ts b/src/arts/sium/core/index.ts index 40d0456..15ae653 100644 --- a/src/arts/sium/core/index.ts +++ b/src/arts/sium/core/index.ts @@ -8,7 +8,12 @@ export { serializeSchema, countLeafFields, walkSchema } from './introspect'; export type { SerializedSchema, SerializedShape } from './introspect'; export { pipe, refine, transform, codec, meta } from './pipe'; export { min, max, length, regex, email, url, integer } from './refines'; -export { SiumValidationError, SiumAsyncSchemaError } from './types'; +export { + SiumAsyncSchemaError, + SiumValidationError, + isSiumAsyncSchemaError, + isSiumValidationError +} from './types'; export type { StandardSchemaV1 } from './standard-schema'; export type { diff --git a/src/arts/sium/core/types.ts b/src/arts/sium/core/types.ts index 93b065c..d5bdadf 100644 --- a/src/arts/sium/core/types.ts +++ b/src/arts/sium/core/types.ts @@ -26,6 +26,8 @@ */ import type { StandardSchemaV1 } from './standard-schema'; +import { CodeError } from '$libs/errs'; +import { SIUM_ERR_ASYNC_SCHEMA, SIUM_ERR_VALIDATION } from '../consts.ts'; // ─── Kind / wrapper / effect vocabulary ───────────────────────────────────── @@ -294,12 +296,13 @@ export interface Step { * `decode` only when you know the input is valid (parsed forms, trusted * storage) and want a cleaner call site. */ -export class SiumValidationError extends Error { +export class SiumValidationError extends CodeError { readonly issues: ReadonlyArray; constructor(issues: ReadonlyArray, message?: string) { - super(message ?? `Validation failed with ${issues.length} issue(s)`); - this.name = 'SiumValidationError'; + super(SIUM_ERR_VALIDATION, { + message: message ?? `Validation failed with ${issues.length} issue(s)` + }); this.issues = issues; } } @@ -311,16 +314,24 @@ export class SiumValidationError extends Error { * Consumers (e.g. `createForm`) detect this structurally via `instanceof` to * fall back to the async API instead of surfacing a confusing generic error. */ -export class SiumAsyncSchemaError extends Error { +export class SiumAsyncSchemaError extends CodeError { readonly schemaKind: SchemaKind | undefined; constructor(schemaKind?: SchemaKind, message?: string) { - super( - message ?? + super(SIUM_ERR_ASYNC_SCHEMA, { + message: + message ?? `Schema contains async steps — use decode() / validate() instead of decodeSync / validateSync.` - ); - this.name = 'SiumAsyncSchemaError'; + }); this.schemaKind = schemaKind; } } +export function isSiumValidationError(value: unknown): value is SiumValidationError { + return value instanceof SiumValidationError; +} + +export function isSiumAsyncSchemaError(value: unknown): value is SiumAsyncSchemaError { + return value instanceof SiumAsyncSchemaError; +} + diff --git a/src/arts/sium/index.ts b/src/arts/sium/index.ts index d7972f1..4e08933 100644 --- a/src/arts/sium/index.ts +++ b/src/arts/sium/index.ts @@ -41,8 +41,10 @@ export { email, url, integer, + SiumAsyncSchemaError, SiumValidationError, - SiumAsyncSchemaError + isSiumAsyncSchemaError, + isSiumValidationError } from './core'; export type { SerializedSchema, diff --git a/src/arts/sium/test/errors.test.ts b/src/arts/sium/test/errors.test.ts new file mode 100644 index 0000000..645a154 --- /dev/null +++ b/src/arts/sium/test/errors.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { + isSiumAsyncSchemaError, + isSiumValidationError, + SiumAsyncSchemaError, + SiumValidationError, + type Issue +} from '../index.ts'; +import { + SIUM_ERR, + SIUM_ERR_ASYNC_SCHEMA, + SIUM_ERR_VALIDATION +} from '../consts.ts'; +import { CodeError, isCodeError, matches } from '$libs/errs'; + +const ISSUE: Issue = { + path: ['name'], + message: 'required', + code: 'invalid_type' +} as Issue; + +describe('SiumValidationError', () => { + it('extends CodeError with the validation code', () => { + const err = new SiumValidationError([ISSUE]); + expect(err).toBeInstanceOf(CodeError); + expect(err.code).toBe(SIUM_ERR_VALIDATION); + expect(err.name).toBe(SIUM_ERR_VALIDATION); + }); + + it('carries issues alongside the code', () => { + const err = new SiumValidationError([ISSUE]); + expect(err.issues).toEqual([ISSUE]); + }); + + it('is detected by isSiumValidationError and isCodeError', () => { + const err = new SiumValidationError([ISSUE]); + expect(isSiumValidationError(err)).toBe(true); + expect(isCodeError(err)).toBe(true); + }); + + it('is matched by the SIUM_ERR module seed', () => { + const err = new SiumValidationError([ISSUE]); + expect(matches(err, SIUM_ERR)).toBe(true); + }); +}); + +describe('SiumAsyncSchemaError', () => { + it('extends CodeError with the async-schema code', () => { + const err = new SiumAsyncSchemaError('object'); + expect(err).toBeInstanceOf(CodeError); + expect(err.code).toBe(SIUM_ERR_ASYNC_SCHEMA); + expect(err.name).toBe(SIUM_ERR_ASYNC_SCHEMA); + }); + + it('carries the schema kind', () => { + const err = new SiumAsyncSchemaError('union'); + expect(err.schemaKind).toBe('union'); + }); + + it('is detected by isSiumAsyncSchemaError and isCodeError', () => { + const err = new SiumAsyncSchemaError(); + expect(isSiumAsyncSchemaError(err)).toBe(true); + expect(isCodeError(err)).toBe(true); + }); + + it('does not match the validation code', () => { + const err = new SiumAsyncSchemaError(); + expect(matches(err, SIUM_ERR_VALIDATION)).toBe(false); + expect(matches(err, SIUM_ERR_ASYNC_SCHEMA)).toBe(true); + }); + + it('is matched by the SIUM_ERR module seed', () => { + const err = new SiumAsyncSchemaError(); + expect(matches(err, SIUM_ERR)).toBe(true); + }); +}); + +describe('CodeError fluent decorators on sium errors', () => { + it('preserves the subclass-specific fields after withMessage', () => { + const err = new SiumValidationError([ISSUE]); + const decorated = err.withMessage('User-facing failure'); + expect(decorated.userMessage).toBe('User-facing failure'); + // Decoration returns plain CodeError; subclass fields stay on the + // original instance only. + expect(matches(decorated, SIUM_ERR_VALIDATION)).toBe(true); + }); +}); diff --git a/src/libs/errs/code-error.ts b/src/libs/errs/code-error.ts index 2b3ebf7..66ead14 100644 --- a/src/libs/errs/code-error.ts +++ b/src/libs/errs/code-error.ts @@ -1,4 +1,10 @@ -import { type ErrCode, isEqualOrDescendantOf, moduleOf } from './code.ts'; +import { + type ErrCode, + isEqualOrDescendantOf, + isModuleSeed, + moduleOf, + type ModuleSeed +} from './code.ts'; /** * Metadata accumulated on an error as it travels through layers. Each @@ -162,20 +168,28 @@ export function isCodeError(value: unknown): value is CodeError { } /** - * `true` when `value` is a `CodeError` whose `code` is `family` or a - * descendant of `family`. Lets a single check cover entire families: + * `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 identity - * matches(err, BUSS_ERR) // any error from the buss module - * matches(err, BUSS_ERR_LISTENER) // any error under the listener sub-tree + * 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 (alphanumeric-only segments + `.` separator) prevents - * false positives like `'buss::listener_extra'` matching the family - * `'buss::listener'`. + * 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): boolean { +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); } diff --git a/src/libs/errs/code.ts b/src/libs/errs/code.ts index 1bc61ea..dcfedaa 100644 --- a/src/libs/errs/code.ts +++ b/src/libs/errs/code.ts @@ -18,17 +18,29 @@ import { import { CodeFormatError } from './errors.ts'; declare const __errCode: unique symbol; +declare const __moduleSeed: 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. + * Construction goes through `code(value)` (throws on invalid), + * `errCode(parent, segment)` (composes from a seed or another code), + * or `sub(parent, segment)`. The brand prevents accidentally passing + * an unvalidated string where an `ErrCode` is expected. */ export type ErrCode = string & { readonly [__errCode]: true }; +/** + * Module prefix string of the form `'::'`. Produced once per + * module via `moduleSeed('module')` and used as the parent argument + * to `errCode(seed, segment)` so individual error codes don't repeat + * the module name in every declaration. + * + * `matches(err, seed)` is true for every code that starts with the + * seed, i.e. for every error declared by that module. + */ +export type ModuleSeed = string & { readonly [__moduleSeed]: true }; + // ── Character predicates ────────────────────────────────────────────── const CODE_LOWER_A = 0x61; // 'a' @@ -228,6 +240,55 @@ export function codeToLangPath(c: ErrCode): string { return c.replace(ERRS_SEP_MODULE, ERRS_SEP_PATH); } +/** + * Build a `ModuleSeed` from a module name. The module name must be a + * valid segment (lowercase alphanumeric + internal underscores). + * + * @throws CodeFormatError when the module name is invalid. + */ +export function moduleSeed(module: string): ModuleSeed { + if (!isValidSegment(module)) { + throw new CodeFormatError(module, ERRS_VALIDATION_REASON_EMPTY_SEGMENT); + } + return `${module}${ERRS_SEP_MODULE}` as ModuleSeed; +} + +/** + * `true` when `value` is a `ModuleSeed` (a string ending in `'::'` + * with a valid module portion). Use to discriminate seeds from + * regular `ErrCode`s. + */ +export function isModuleSeed(value: string): value is ModuleSeed { + if (!value.endsWith(ERRS_SEP_MODULE)) return false; + const module = value.slice(0, -ERRS_SEP_MODULE.length); + if (module.length === 0) return false; + return isValidSegment(module); +} + +/** + * Compose an `ErrCode` from a parent (seed or existing code) and a + * segment. Picks the right separator: `seed + segment` for a seed, + * `parent.segment` for an existing code. + * + * ```ts + * const BUSS_ERR = moduleSeed('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' + * ``` + * + * @throws CodeFormatError when `segment` is not a valid segment. + */ +export function errCode(parent: ModuleSeed | ErrCode, segment: string): ErrCode { + if (!isValidSegment(segment)) { + throw new CodeFormatError(segment, ERRS_VALIDATION_REASON_EMPTY_SEGMENT); + } + if (isModuleSeed(parent)) { + return `${parent}${segment}` as ErrCode; + } + return `${parent}${ERRS_SEP_PATH}${segment}` as ErrCode; +} + // ── Internal helpers ────────────────────────────────────────────────── function isValidSegment(segment: string): boolean { diff --git a/src/libs/errs/index.ts b/src/libs/errs/index.ts index 1889450..ea9e409 100644 --- a/src/libs/errs/index.ts +++ b/src/libs/errs/index.ts @@ -36,17 +36,20 @@ export { CodeFormatError, isCodeFormatError } from './errors.ts'; export { code, codeToLangPath, + errCode, isDescendantOf, isEqualOrDescendantOf, + isModuleSeed, isValidCode, leaf, moduleOf, + moduleSeed, parent, parts, sub, validateCode } from './code.ts'; -export type { ErrCode } from './code.ts'; +export type { ErrCode, ModuleSeed } from './code.ts'; export { CodeError, isCodeError, matches } from './code-error.ts'; export type { CodeErrorMeta } from './code-error.ts'; diff --git a/src/libs/errs/test/code-error.test.ts b/src/libs/errs/test/code-error.test.ts index be03c6b..71b5d08 100644 --- a/src/libs/errs/test/code-error.test.ts +++ b/src/libs/errs/test/code-error.test.ts @@ -1,16 +1,18 @@ import { describe, expect, it } from 'vitest'; import { - code, CodeError, + errCode, isCodeError, - matches + matches, + moduleSeed } 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'); +const BUSS_ERR = moduleSeed('buss'); +const SESS_ERR = moduleSeed('sess'); +const MOD_DISPOSED = errCode(BUSS_ERR, 'disposed'); +const MOD_LISTENER = errCode(BUSS_ERR, 'listener'); +const MOD_LISTENER_FAILED = errCode(MOD_LISTENER, 'failed'); +const MOD_OTHER = errCode(SESS_ERR, 'expired'); describe('CodeError — construction', () => { it('uses the code as the error name', () => { @@ -178,7 +180,6 @@ describe('matches()', () => { 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', () => { @@ -203,3 +204,23 @@ describe('matches()', () => { expect(matches(err, MOD_LISTENER_FAILED)).toBe(true); }); }); + +describe('matches() with a ModuleSeed', () => { + it('returns true for any CodeError from the same module', () => { + const err1 = new CodeError(MOD_DISPOSED); + const err2 = new CodeError(MOD_LISTENER_FAILED); + expect(matches(err1, BUSS_ERR)).toBe(true); + expect(matches(err2, BUSS_ERR)).toBe(true); + }); + + it('returns false for a CodeError from a different module', () => { + const err = new CodeError(MOD_OTHER); + expect(matches(err, BUSS_ERR)).toBe(false); + }); + + it('returns false for non-CodeError values', () => { + expect(matches(new Error('x'), BUSS_ERR)).toBe(false); + expect(matches(null, BUSS_ERR)).toBe(false); + expect(matches(undefined, BUSS_ERR)).toBe(false); + }); +}); diff --git a/src/libs/errs/test/code.test.ts b/src/libs/errs/test/code.test.ts index 54079f8..9288422 100644 --- a/src/libs/errs/test/code.test.ts +++ b/src/libs/errs/test/code.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it } from 'vitest'; import { code, codeToLangPath, + errCode, isDescendantOf, isEqualOrDescendantOf, + isModuleSeed, isValidCode, leaf, moduleOf, + moduleSeed, parent, parts, sub, @@ -230,3 +233,67 @@ describe('codeToLangPath()', () => { expect(codeToLangPath(c)).toBe('a.b.c'); }); }); + +describe('moduleSeed()', () => { + it('produces a seed of the form "::"', () => { + expect(moduleSeed('buss')).toBe('buss::'); + expect(moduleSeed('sess')).toBe('sess::'); + }); + + it('throws on invalid module names', () => { + expect(() => moduleSeed('')).toThrow(); + expect(() => moduleSeed('Buss')).toThrow(); + expect(() => moduleSeed('a-b')).toThrow(); + expect(() => moduleSeed('_buss')).toThrow(); + }); + + it('accepts internal underscores', () => { + expect(moduleSeed('libs_aapp')).toBe('libs_aapp::'); + }); +}); + +describe('isModuleSeed()', () => { + it('returns true for valid seeds', () => { + expect(isModuleSeed('buss::')).toBe(true); + expect(isModuleSeed(moduleSeed('sess'))).toBe(true); + }); + + it('returns false for full ErrCodes', () => { + expect(isModuleSeed('buss::disposed')).toBe(false); + expect(isModuleSeed(code('buss::disposed'))).toBe(false); + }); + + it('returns false for malformed strings', () => { + expect(isModuleSeed('buss')).toBe(false); + expect(isModuleSeed('::')).toBe(false); + expect(isModuleSeed('Buss::')).toBe(false); + }); +}); + +describe('errCode()', () => { + it('appends segment to a seed with no separator', () => { + const seed = moduleSeed('buss'); + expect(errCode(seed, 'disposed')).toBe('buss::disposed'); + }); + + it('appends segment to an existing code with a dot', () => { + const seed = moduleSeed('buss'); + const listener = errCode(seed, 'listener'); + expect(errCode(listener, 'failed')).toBe('buss::listener.failed'); + }); + + it('chains naturally for deeper hierarchies', () => { + const seed = moduleSeed('auth'); + const user = errCode(seed, 'user'); + const login = errCode(user, 'login'); + expect(errCode(login, 'failed')).toBe('auth::user.login.failed'); + }); + + it('throws on invalid segment', () => { + const seed = moduleSeed('buss'); + expect(() => errCode(seed, '')).toThrow(); + expect(() => errCode(seed, 'A')).toThrow(); + expect(() => errCode(seed, 'a-b')).toThrow(); + expect(() => errCode(seed, '_a')).toThrow(); + }); +});