Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export const errors = errata({
})

// Usage
errors.throw('auth.invalid_token', { reason: 'expired' })
throw errors.create('auth.invalid_token', { reason: 'expired' })
const err = errors.create('auth.rate_limited') // uses default retryAfter

if (errors.hasTag(err, 'auth')) {
Expand Down
24 changes: 20 additions & 4 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,31 @@ interface ErrataPlugin<TPluginCodes extends CodeConfigRecord> {
codes?: TPluginCodes

/**
* Hook: Input Mapping
* Hook: Unknown Mapping
* Runs inside `errors.ensure(err)`.
* @param error - The raw unknown error being ensured.
* @param ctx - The errata instance (restricted context).
* @returns ErrataError instance OR { code, details } OR null (to pass).
*/
onEnsure?: (error: unknown, ctx: ErrataContext) => ErrataError | { code: string, details?: any } | null
onUnknown?: (error: unknown, ctx: ErrataContext) => ErrataError | { code: string, details?: any } | null

/**
* Hook: Side Effects
* Runs synchronously inside `errors.create()` (and by extension `throw`).
* Runs synchronously inside `errors.create()`.
* @param error - The fully formed ErrataError instance.
* @param ctx - The errata instance.
*/
onCreate?: (error: ErrataError, ctx: ErrataContext) => void

/**
* Hook: Serialization Adaptation
* Runs inside `errors.serialize(err)`.
* @param payload - The current serialized payload (mutable).
* @param error - The original ErrataError instance.
* @param ctx - The errata instance.
* @returns A SerializedError (can be the same object or a modified clone).
*/
onSerialize?: (payload: SerializedError, error: ErrataError, ctx: ErrataContext) => SerializedError
}
```

Expand All @@ -70,7 +80,7 @@ The `errata` factory must be updated to accept a `plugins` array.
1. **`errors.ensure(err)` Flow:**

* Iterate through `plugins` in order.
* Call `plugin.onEnsure(err, ctx)`.
* Call `plugin.onUnknown(err, ctx)`.
* **Stop** at the first plugin that returns a non-null value.
* Use that value to return the final `ErrataError`.
* *Fallback:* If no plugin handles it, proceed with standard normalization (check `instanceof Error`, etc.).
Expand All @@ -82,6 +92,12 @@ The `errata` factory must be updated to accept a `plugins` array.
* Call `plugin.onCreate(error, ctx)` for **all** plugins (side effects are independent).
* Wrap each call in try/catch; if an error occurs, `console.error('errata: plugin [name] crashed in onCreate', err)`.

3. **`errors.serialize(err)` Flow:**

* Build base payload via `err.toJSON()`.
* Iterate through `plugins` in order.
* Call `plugin.onSerialize(payload, error, ctx)` when defined and use its return value as the new payload.

### Plugin Validation (at initialization)

When `errata({ plugins: [] })` initializes:
Expand Down
186 changes: 127 additions & 59 deletions packages/errata/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,21 @@ import type {
CodeOf,
CodesForTag,
CodesRecord,
ErrataClientErrorForCodes,
ErrataClientPlugin,
MatchingErrataClientError,
Pattern,
PatternInput,
InternalCode,
InternalDetails,
MatchingErrataClientErrorForCodes,
PatternForCodes,
PatternInputForCodes,
} from './types'

import { LIB_NAME } from './types'
import { findBestMatchingPattern, matchesPattern } from './utils/pattern-matching'

// ─── Client Types ─────────────────────────────────────────────────────────────

type InternalClientCode = 'be.unknown_error' | 'be.deserialization_failed' | 'be.network_error'
type ClientCode<TCodes extends CodesRecord> = CodeOf<TCodes> | InternalClientCode
type ClientCode<TCodes extends CodesRecord> = CodeOf<TCodes> | InternalCode

export class ErrataClientError<C extends string = string, D = unknown> extends Error {
override readonly name = 'ErrataClientError'
Expand Down Expand Up @@ -47,53 +49,80 @@ export class ErrataClientError<C extends string = string, D = unknown> extends E
/**
* Client match handlers object type.
*/
export type ClientMatchHandlers<TCodes extends CodesRecord, R> = {
[K in Pattern<TCodes>]?: (e: MatchingErrataClientError<TCodes, K>) => R
type ClientMatchHandlersForUnion<
TCodes extends CodesRecord,
TUnion extends string,
R,
> = {
[K in PatternForCodes<TUnion>]?: (e: MatchingErrataClientErrorForCodes<TCodes, TUnion, K>) => R
} & {
default?: (e: ErrataClientError<ClientCode<TCodes>>) => R
default?: (e: ErrataClientErrorForCodes<TCodes, TUnion>) => R
}

export type ClientMatchHandlers<TCodes extends CodesRecord, R> = ClientMatchHandlersForUnion<
TCodes,
ClientCode<TCodes>,
R
>

/**
* Client-side surface derived from a server `errors` type.
*/
export interface ErrorClient<TCodes extends CodesRecord> {
/** Client-side ErrataError constructor for instanceof checks. */
ErrataError: new (
payload: SerializedError<ClientCode<TCodes>, any>
) => ErrataClientError<ClientCode<TCodes>, any>
) => ErrataClientErrorForCodes<TCodes, ClientCode<TCodes>>
/** Turn a serialized payload into a client error instance. */
deserialize: (
payload: unknown,
) => ErrataClientError<ClientCode<TCodes>, any>
) => ErrataClientErrorForCodes<TCodes, ClientCode<TCodes>>
/** Normalize unknown errors into ErrataClientError. */
ensure: (
err: unknown,
) => ErrataClientErrorForCodes<TCodes, ClientCode<TCodes>>
/**
* Type-safe pattern check; supports exact codes, wildcard patterns (`'auth.*'`),
* and arrays of patterns. Returns a type guard narrowing the error type.
*/
is: <P extends PatternInput<TCodes> | readonly PatternInput<TCodes>[]>(
err: unknown,
pattern: P,
) => err is MatchingErrataClientError<TCodes, P>
is: {
<C extends ClientCode<TCodes>, P extends PatternInputForCodes<C> | readonly PatternInputForCodes<C>[]>(
err: ErrataClientErrorForCodes<TCodes, C>,
pattern: P,
): err is MatchingErrataClientErrorForCodes<TCodes, C, P>
(
err: unknown,
pattern: PatternInputForCodes<ClientCode<TCodes>> | readonly PatternInputForCodes<ClientCode<TCodes>>[],
): boolean
}
/**
* Pattern matcher over codes with priority: exact match > longest wildcard > default.
* Supports exact codes, wildcard patterns (`'auth.*'`), and a `default` handler.
*/
match: <R>(
err: unknown,
handlers: ClientMatchHandlers<TCodes, R>,
) => R | undefined
match: {
<C extends ClientCode<TCodes>, R>(
err: ErrataClientErrorForCodes<TCodes, C>,
handlers: ClientMatchHandlersForUnion<TCodes, C, R>,
): R | undefined
<R>(
err: unknown,
handlers: ClientMatchHandlersForUnion<TCodes, ClientCode<TCodes>, R>,
): R | undefined
}
/** Check whether an error carries a given tag. */
hasTag: <TTag extends string>(
err: unknown,
tag: TTag,
) => err is MatchingErrataClientError<
) => err is MatchingErrataClientErrorForCodes<
TCodes,
CodeOf<TCodes>,
Extract<CodesForTag<TCodes, TTag>, CodeOf<TCodes>>
>
/** Promise helper that returns a tuple without try/catch. */
safe: <T>(
promise: Promise<T>,
) => Promise<
[data: T, error: null] | [data: null, error: ErrataClientError<ClientCode<TCodes>, any>]
[data: T, error: null] | [data: null, error: ErrataClientErrorForCodes<TCodes, ClientCode<TCodes>>]
>
}

Expand All @@ -106,6 +135,14 @@ export interface ErrorClientOptions {
app?: string
/** Optional lifecycle plugins (payload adaptation, logging). */
plugins?: ErrataClientPlugin[]
/**
* Called when normalizing unknown values that are not recognized as serialized errors.
* Return a code (string) to map it; return null/undefined to fall back to errata.unknown_error.
*/
onUnknown?: (
error: unknown,
ctx: ClientContext,
) => string | null | undefined
}

/**
Expand All @@ -115,13 +152,14 @@ export interface ErrorClientOptions {
export function createErrorClient<TServer extends ErrataInstance<any>>(
options: ErrorClientOptions = {},
): ErrorClient<InferCodes<TServer>> {
const { app, plugins = [] } = options
const { app, plugins = [], onUnknown } = options

type TCodes = InferCodes<TServer>
type Code = CodeOf<TCodes>
type ClientCode = Code | 'be.unknown_error' | 'be.deserialization_failed' | 'be.network_error'
type TaggedErrataClientError<TTag extends string> = MatchingErrataClientError<
type ClientBoundaryCode = ClientCode<TCodes>
type TaggedErrataClientError<TTag extends string> = MatchingErrataClientErrorForCodes<
TCodes,
Code,
Extract<CodesForTag<TCodes, TTag>, Code>
>

Expand All @@ -141,16 +179,16 @@ export function createErrorClient<TServer extends ErrataInstance<any>>(
const getContext = (): ClientContext => ({ config })

const internal = (
code: Extract<ClientCode, `be.${string}`>,
code: InternalCode,
raw: unknown,
): ErrataClientError<ClientCode, { raw: unknown }> => {
return new ErrataClientError<ClientCode, { raw: unknown }>({
): ErrataClientErrorForCodes<TCodes, InternalCode> => {
return new ErrataClientError<InternalCode, InternalDetails>({
__brand: LIB_NAME,
code,
message: code,
details: { raw },
tags: [],
})
}) as ErrataClientErrorForCodes<TCodes, InternalCode>
}

/** Run onCreate hooks for all plugins (side effects are independent). */
Expand All @@ -171,7 +209,7 @@ export function createErrorClient<TServer extends ErrataInstance<any>>(
/** Turn an unknown payload into a client error instance with defensive checks. */
const deserialize = (
payload: unknown,
): ErrataClientError<ClientCode, any> => {
): ErrataClientErrorForCodes<TCodes, ClientBoundaryCode> => {
// Try plugin onDeserialize hooks (first non-null wins)
const ctx = getContext()
for (const plugin of plugins) {
Expand All @@ -190,53 +228,94 @@ export function createErrorClient<TServer extends ErrataInstance<any>>(
}

// Standard deserialization logic
let error: ErrataClientError<ClientCode, any>
let error: ErrataClientErrorForCodes<TCodes, ClientBoundaryCode>
if (payload && typeof payload === 'object') {
const withCode = payload as { code?: unknown }
if (typeof withCode.code === 'string') {
error = new ErrataClientError(withCode as SerializedError<ClientCode, any>)
error = new ErrataClientError(withCode as SerializedError<ClientBoundaryCode, any>)
}
else if (onUnknown) {
const mapped = onUnknown(payload, getContext())
if (mapped) {
error = new ErrataClientError({
__brand: LIB_NAME,
code: mapped as ClientBoundaryCode,
message: String(mapped),
details: payload as any,
tags: [],
})
runOnCreateHooks(error)
return error
}
error = internal('errata.unknown_error', payload)
}
else {
error = internal('be.deserialization_failed', payload)
error = internal('errata.unknown_error', payload)
}
}
else if (onUnknown) {
const mapped = onUnknown(payload, getContext())
if (mapped) {
error = new ErrataClientError({
__brand: LIB_NAME,
code: mapped as ClientBoundaryCode,
message: String(mapped),
details: payload as any,
tags: [],
})
runOnCreateHooks(error)
return error
}
error = internal('errata.unknown_error', payload)
}
else {
error = internal('be.unknown_error', payload)
error = internal('errata.unknown_error', payload)
}

runOnCreateHooks(error)
return error
}

/** Normalize unknown input into a client error (plugin-first). */
const ensure = (
err: unknown,
): ErrataClientErrorForCodes<TCodes, ClientBoundaryCode> => {
// Pass through existing client errors
if (err instanceof ErrataClientError) {
return err
}

// Normalize via deserialize pipeline (includes onUnknown)
return deserialize(err)
}

/** Type-safe pattern check; supports exact codes, wildcard patterns, and arrays. */
const is = <P extends PatternInput<TCodes> | readonly PatternInput<TCodes>[]>(
const is = ((
err: unknown,
pattern: P,
): err is MatchingErrataClientError<TCodes, P> => {
pattern: PatternInputForCodes<ClientBoundaryCode> | readonly PatternInputForCodes<ClientBoundaryCode>[],
): boolean => {
if (!(err instanceof ErrataClientError))
return false

const patterns = Array.isArray(pattern) ? pattern : [pattern]
return patterns.some(p => matchesPattern(err.code, p as string))
}
}) as ErrorClient<TCodes>['is']

/** Pattern matcher with priority: exact match > longest wildcard > default. */
const match = <R>(
const match = ((
err: unknown,
handlers: ClientMatchHandlers<TCodes, R>,
): R | undefined => {
if (!(err instanceof ErrataClientError)) {
return (handlers as any).default?.(err)
}
handlers: ClientMatchHandlersForUnion<TCodes, ClientBoundaryCode, any>,
): any => {
const errataErr = err instanceof ErrataClientError ? err : ensure(err)

const handlerKeys = Object.keys(handlers).filter(k => k !== 'default')
const matchedPattern = findBestMatchingPattern(err.code, handlerKeys)
const matchedPattern = findBestMatchingPattern(errataErr.code, handlerKeys)
const handler = matchedPattern
? (handlers as any)[matchedPattern]
: (handlers as any).default

return handler ? handler(err) : undefined
}
return handler ? handler(errataErr) : undefined
}) as ErrorClient<TCodes>['match']

/** Check whether an error carries a given tag. */
const hasTag = <TTag extends string>(
Expand All @@ -252,32 +331,21 @@ export function createErrorClient<TServer extends ErrataInstance<any>>(
const safe = async <T>(
promise: Promise<T>,
): Promise<
[data: T, error: null] | [data: null, error: ErrataClientError<ClientCode, any>]
[data: T, error: null] | [data: null, error: ErrataClientErrorForCodes<TCodes, ClientBoundaryCode>]
> => {
try {
const data = await promise
return [data, null]
}
catch (err) {
if (err instanceof ErrataClientError) {
return [null, err]
}

if (err instanceof TypeError) {
return [null, internal('be.network_error', err)]
}

if (err && typeof err === 'object') {
return [null, deserialize(err)]
}

return [null, deserialize(err)]
return [null, ensure(err)]
}
}

return {
ErrataError: ErrataClientError,
deserialize,
ensure,
is,
match,
hasTag,
Expand Down
Loading