From 0b47e7667c5bfcca30615a0dff787b0bd5ced92c Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 15 Sep 2026 03:31:51 +0000 Subject: [PATCH 1/2] feat(errors): preserve causes and expose producer classification --- packages/errors/README.md | 58 ++- .../errors/__tests__/cause-provenance.test.ts | 406 ++++++++++++++++++ packages/errors/src/error.ts | 4 +- packages/errors/src/factory.ts | 21 +- packages/errors/src/parse.ts | 9 +- packages/errors/src/types.ts | 6 + 6 files changed, 494 insertions(+), 10 deletions(-) create mode 100644 packages/errors/__tests__/cause-provenance.test.ts diff --git a/packages/errors/README.md b/packages/errors/README.md index 4668324e57..c708f7a2da 100644 --- a/packages/errors/README.md +++ b/packages/errors/README.md @@ -8,7 +8,7 @@ service or client without pulling in pgpm. - **`parse(anyError)`** — normalize an error from any source (a `ConstructiveError`, a node-postgres `DatabaseError`, a GraphQL error or `{ errors: [...] }` wrapper, a plain `Error`, or a string) into a canonical - `{ code, context, class, known }`. + `{ code, context, class, known, explicitClass? }`. - **`format(code, context, locale)`** — render a localized, interpolated message. `{{var}}` placeholders + registerable per-locale catalogs (i18n). - **`errors.*` factory** — type-safe throwable builders derived from the @@ -49,6 +49,62 @@ throw errors.ACCOUNT_EXISTS(); pgpm CLI codes). These override the generated entries. - Unregistered codes still `parse()` and are classified `internal` (masked). +## Causes and wrapping + +`ConstructiveError` accepts an optional `cause: unknown`. It uses native +`Error.cause`, preserving the original value and any existing cause chain. +`toError(caught)` sets the new error's cause to `caught`; an existing +`ConstructiveError` is returned unchanged, without adding a self-reference. +The cause is non-enumerable and is excluded from `toExtensions()` and ordinary +JSON serialization. Adding a cause does not change the message or context. + +Factories accept an optional third argument, `ErrorFactoryOptions`. The existing +context and override-message arguments keep their positions and behavior: + +```ts +import { errors, toError } from '@constructive-io/errors'; + +const original = new Error('upstream lookup failed'); +const wrapped = errors.MODULE_NOT_FOUND({ name: 'auth' }, undefined, { + cause: original +}); +wrapped.cause === original; // true + +const normalized = toError(original); +normalized.cause === original; // true +``` + +The same options work with `makeErrorFromDefinition()` and the factory returned +by `makeError()`. Omitting `cause` leaves the native property absent; explicitly +supplying `cause: undefined` creates a non-enumerable property with that value. + +## Producer classification + +`parse()` keeps its existing classification policy: a valid producer class wins, +then the registry is consulted, and unknown codes default to internal. +`explicitClass` is additional metadata, present only when the parser actually +used a valid producer classification from `ConstructiveError.errorClass`, +PostgreSQL `DETAIL.class`, or GraphQL `extensions.class` (including request +wrappers). Missing or invalid producer classes do not populate it. The existing +code-selection precedence also governs which transport's class can be used. + +```ts +import { parse } from '@constructive-io/errors'; + +const parsed = parse({ message: 'STORAGE_PROCESSING_CONFLICT' }); +parsed.class; // 'public', from the registry +parsed.explicitClass; // undefined + +// An adapter can retain its own internal default for undeclared classifications +// without duplicating the DETAIL or GraphQL parser. +const adapterClass = parsed.explicitClass ?? 'internal'; +``` + +This describes the **immediate input**, not the origin of its cause. A canonical +error is authoritative for its class, including errors created by registry +factories or `toError()`. To distinguish the raw producer's class from a registry +fallback, inspect `parse(caught)` before normalizing it with `toError()`. + ## HTTP status Every registry entry carries `http`, so an HTTP surface never needs its own diff --git a/packages/errors/__tests__/cause-provenance.test.ts b/packages/errors/__tests__/cause-provenance.test.ts new file mode 100644 index 0000000000..45c15ae5b9 --- /dev/null +++ b/packages/errors/__tests__/cause-provenance.test.ts @@ -0,0 +1,406 @@ +import { + ConstructiveError, + errors, + getDefinition, + makeError, + makeErrorFromDefinition, + parse, + toError, +} from '../src'; + +function hasOwnCause(error: Error): boolean { + return Object.prototype.hasOwnProperty.call(error, 'cause'); +} + +function expectNativeCause(error: Error, expected: unknown): void { + expect(hasOwnCause(error)).toBe(true); + expect(error.cause).toBe(expected); + expect(Object.getOwnPropertyDescriptor(error, 'cause')).toMatchObject({ + value: expected, + writable: true, + enumerable: false, + configurable: true, + }); +} + +describe('native cause provenance', () => { + it('preserves object identity and nested native causes', () => { + const nestedCause = { requestId: 'req-1' }; + const original = new Error('upstream failure', { cause: nestedCause }); + const normalized = toError(original); + const canonicalCause = { operation: 'lookup' }; + const canonical = new ConstructiveError({ + code: 'ACCOUNT_EXISTS', + message: 'account exists', + errorClass: 'public', + http: 409, + cause: canonicalCause, + }); + + expectNativeCause(normalized, original); + expect((normalized.cause as Error).cause).toBe(nestedCause); + expectNativeCause(canonical, canonicalCause); + }); + + it.each([ + ['null', null], + ['undefined', undefined], + ['string', 'a primitive failure'], + ['number', 17], + ['boolean', false], + ])('preserves a %s thrown value', (_label, thrownValue) => { + expect(toError(thrownValue).cause).toBe(thrownValue); + }); + + it('preserves identity for an arbitrary thrown object', () => { + const thrownObject = { source: 'adapter', nested: { retryable: true } }; + + expect(toError(thrownObject).cause).toBe(thrownObject); + }); + + it('distinguishes an absent cause from an explicitly supplied undefined cause', () => { + const withoutCause = new ConstructiveError({ + code: 'ACCOUNT_EXISTS', + message: 'account exists', + errorClass: 'public', + http: 409, + }); + const withUndefinedCause = new ConstructiveError({ + code: 'ACCOUNT_EXISTS', + message: 'account exists', + errorClass: 'public', + http: 409, + cause: undefined, + }); + + expect(hasOwnCause(withoutCause)).toBe(false); + expect(Object.keys(withoutCause)).not.toContain('cause'); + expectNativeCause(withUndefinedCause, undefined); + }); + + it('excludes cause from GraphQL extensions and JSON serialization', () => { + const rawCause = { secret: 'do-not-serialize', nested: { token: 'hidden' } }; + const error = new ConstructiveError({ + code: 'ACCOUNT_EXISTS', + message: 'account exists', + errorClass: 'public', + http: 409, + cause: rawCause, + }); + const extensions = error.toExtensions(); + + expect(extensions).toEqual({ code: 'ACCOUNT_EXISTS', class: 'public', http: 409 }); + expect(extensions).not.toHaveProperty('cause'); + expect(JSON.stringify(error)).not.toContain('do-not-serialize'); + expect(JSON.stringify(extensions)).not.toContain('do-not-serialize'); + }); + + it('returns a canonical error unchanged', () => { + const cause = new Error('original failure'); + const canonical = errors.ACCOUNT_EXISTS({}, 'canonical override', { cause }); + + expect(toError(canonical)).toBe(canonical); + expect(canonical.cause).toBe(cause); + expect((canonical.cause as Error).cause).toBeUndefined(); + }); +}); + +describe('factory cause options', () => { + it('supports typed, empty, generated, definition, and inline factories', () => { + const cause = { source: 'factory test' }; + const typed = errors.MODULE_NOT_FOUND({ name: 'auth' }, 'typed override', { cause }); + const empty = errors.ACCOUNT_EXISTS({}, 'empty override', { cause }); + const generated = errors.API_KEY_LIMIT_REACHED( + { resource: 'api_keys', limit: 5 }, + 'generated override', + { cause }, + ); + const definition = makeErrorFromDefinition(getDefinition('STORAGE_PROCESSING_CONFLICT')!)( + { database_id: 'db-1', file_id: 'file-1' }, + 'definition override', + { cause }, + ); + const inline = makeError<{ value: string }>( + 'STORAGE_PROCESSING_CONFLICT', + ({ value }) => `inline ${value}`, + 409, + 'public', + )({ value: 'context' }, 'inline override', { cause }); + + expect(typed).toMatchObject({ + code: 'MODULE_NOT_FOUND', + message: 'typed override', + context: { name: 'auth' }, + }); + expect(empty).toMatchObject({ + code: 'ACCOUNT_EXISTS', + message: 'empty override', + context: {}, + }); + expect(generated).toMatchObject({ + code: 'API_KEY_LIMIT_REACHED', + message: 'generated override', + context: { resource: 'api_keys', limit: 5 }, + }); + expect(definition).toMatchObject({ + code: 'STORAGE_PROCESSING_CONFLICT', + message: 'definition override', + context: { database_id: 'db-1', file_id: 'file-1' }, + }); + expect(inline).toMatchObject({ + code: 'STORAGE_PROCESSING_CONFLICT', + message: 'inline override', + context: { value: 'context' }, + }); + + for (const error of [typed, empty, generated, definition, inline]) { + expectNativeCause(error, cause); + } + }); + + it('omits the native cause property when options are omitted', () => { + const definition = makeErrorFromDefinition(getDefinition('STORAGE_PROCESSING_CONFLICT')!); + const inline = makeError<{ value: string }>( + 'STORAGE_PROCESSING_CONFLICT', + ({ value }) => value, + ); + const factoryErrors = [ + errors.MODULE_NOT_FOUND({ name: 'auth' }), + errors.ACCOUNT_EXISTS(), + errors.API_KEY_LIMIT_REACHED({}), + definition({}), + inline({ value: 'context' }), + ]; + + for (const error of factoryErrors) { + expect(hasOwnCause(error)).toBe(false); + } + }); + it('distinguishes empty factory options from an explicit undefined cause', () => { + const inline = makeError<{ value: string }>('STORAGE_PROCESSING_CONFLICT', ({ value }) => value); + expect(hasOwnCause(errors.ACCOUNT_EXISTS({}, undefined, {}))).toBe(false); + expectNativeCause(errors.ACCOUNT_EXISTS({}, undefined, { cause: undefined }), undefined); + expect(hasOwnCause(inline({ value: 'context' }, undefined, {}))).toBe(false); + expectNativeCause(inline({ value: 'context' }, undefined, { cause: undefined }), undefined); + }); +}); + +describe('producer class provenance', () => { + const unknownCode = 'ERROR_PROVENANCE_TEST_UNREGISTERED'; + + it('records valid DETAIL classes for unknown public and registered public codes overridden as internal', () => { + const unknownPublic = parse({ + message: 'detail message', + code: 'P0001', + detail: JSON.stringify({ + code: unknownCode, + context: { source: 'detail' }, + class: 'public', + }), + }); + const registeredInternal = parse({ + message: 'detail message', + code: 'P0001', + detail: JSON.stringify({ + code: 'ACCOUNT_EXISTS', + context: { source: 'detail' }, + class: 'internal', + }), + }); + + expect(unknownPublic).toMatchObject({ + code: unknownCode, + context: { source: 'detail' }, + class: 'public', + explicitClass: 'public', + known: false, + }); + expect(registeredInternal).toMatchObject({ + code: 'ACCOUNT_EXISTS', + context: { source: 'detail' }, + class: 'internal', + explicitClass: 'internal', + known: true, + }); + }); + + it('falls back to registry or internal classification for invalid and missing DETAIL classes', () => { + const cases = [ + { + code: 'STORAGE_PROCESSING_CONFLICT', + detail: { code: 'STORAGE_PROCESSING_CONFLICT', context: {}, class: 'invalid' }, + expectedClass: 'public', + known: true, + }, + { + code: 'STORAGE_PROCESSING_CONFLICT', + detail: { code: 'STORAGE_PROCESSING_CONFLICT', context: {} }, + expectedClass: 'public', + known: true, + }, + { + code: unknownCode, + detail: { code: unknownCode, context: {}, class: 'invalid' }, + expectedClass: 'internal', + known: false, + }, + { + code: unknownCode, + detail: { code: unknownCode, context: {} }, + expectedClass: 'internal', + known: false, + }, + ]; + + for (const testCase of cases) { + const result = parse({ + message: testCase.code, + code: 'P0001', + detail: JSON.stringify(testCase.detail), + }); + + expect(result.code).toBe(testCase.code); + expect(result.class).toBe(testCase.expectedClass); + expect(result.known).toBe(testCase.known); + expect(result.explicitClass).toBeUndefined(); + } + }); + + it('records direct and wrapped GraphQL producer classes', () => { + const direct = parse({ + message: 'graphql message', + extensions: { + code: 'ACCOUNT_EXISTS', + context: { source: 'graphql' }, + class: 'internal', + }, + }); + const wrapped = parse({ + errors: [ + { + message: 'wrapped graphql message', + extensions: { + code: unknownCode, + context: { source: 'wrapped' }, + class: 'public', + }, + }, + ], + }); + + expect(direct).toMatchObject({ + code: 'ACCOUNT_EXISTS', + context: { source: 'graphql' }, + class: 'internal', + explicitClass: 'internal', + known: true, + }); + expect(wrapped).toMatchObject({ + code: unknownCode, + context: { source: 'wrapped' }, + class: 'public', + explicitClass: 'public', + known: false, + }); + }); + + it('omits producer metadata when GraphQL class is missing or invalid', () => { + for (const classification of [undefined, 'invalid']) { + const result = parse({ + extensions: { code: 'STORAGE_PROCESSING_CONFLICT', class: classification }, + }); + expect(result.class).toBe('public'); + expect(result).not.toHaveProperty('explicitClass'); + } + }); + + it('keeps DETAIL precedence and does not borrow a GraphQL class when DETAIL omits one', () => { + const detailWins = parse({ + message: 'detail wins', + code: 'P0001', + detail: JSON.stringify({ + code: unknownCode, + context: { selected: 'detail' }, + class: 'public', + }), + extensions: { + code: 'ACCOUNT_EXISTS', + context: { selected: 'graphql' }, + class: 'internal', + }, + }); + const registryFallback = parse({ + message: 'detail class is absent', + code: 'P0001', + detail: JSON.stringify({ + code: 'STORAGE_PROCESSING_CONFLICT', + context: { selected: 'detail' }, + }), + extensions: { + code: 'ACCOUNT_EXISTS', + context: { selected: 'graphql' }, + class: 'internal', + }, + }); + + expect(detailWins).toMatchObject({ + code: unknownCode, + context: { selected: 'detail' }, + class: 'public', + explicitClass: 'public', + known: false, + }); + expect(registryFallback).toMatchObject({ + code: 'STORAGE_PROCESSING_CONFLICT', + context: { selected: 'detail' }, + class: 'public', + known: true, + }); + expect(registryFallback.explicitClass).toBeUndefined(); + }); + + it('treats a canonical instance as the immediate classified producer', () => { + const canonical = new ConstructiveError({ + code: 'ACCOUNT_EXISTS', + message: 'canonical message', + errorClass: 'internal', + http: 500, + context: { source: 'canonical' }, + }); + + const result = parse(canonical); + + expect(result).toMatchObject({ + code: 'ACCOUNT_EXISTS', + context: { source: 'canonical' }, + class: 'internal', + explicitClass: 'internal', + known: true, + rawMessage: 'canonical message', + originalError: canonical, + }); + }); + + it('marks toError output with its own immediate class metadata', () => { + const raw = { + message: 'raw producer message', + code: 'P0001', + detail: JSON.stringify({ code: 'STORAGE_PROCESSING_CONFLICT', context: { source: 'raw' } }), + }; + const rawParsed = parse(raw); + const normalized = toError(raw); + const normalizedParsed = parse(normalized); + + expect(rawParsed.class).toBe('public'); + expect(rawParsed.explicitClass).toBeUndefined(); + expect(normalized.cause).toBe(raw); + expect(normalizedParsed).toMatchObject({ + code: 'STORAGE_PROCESSING_CONFLICT', + context: { source: 'raw' }, + class: 'public', + explicitClass: 'public', + known: true, + originalError: normalized, + }); + }); +}); diff --git a/packages/errors/src/error.ts b/packages/errors/src/error.ts index 98f9269f15..e22ebab556 100644 --- a/packages/errors/src/error.ts +++ b/packages/errors/src/error.ts @@ -6,6 +6,8 @@ export interface ConstructiveErrorArgs { errorClass: ErrorClass; http: number; context?: ErrorContext; + /** Original failure, retained as a native non-enumerable Error.cause. */ + cause?: unknown; } /** @@ -22,7 +24,7 @@ export class ConstructiveError extends Error { readonly context?: ErrorContext; constructor(args: ConstructiveErrorArgs) { - super(args.message); + super(args.message, 'cause' in args ? { cause: args.cause } : undefined); this.name = 'ConstructiveError'; this.code = args.code; this.errorClass = args.errorClass; diff --git a/packages/errors/src/factory.ts b/packages/errors/src/factory.ts index 8d34d01c95..7747b517da 100644 --- a/packages/errors/src/factory.ts +++ b/packages/errors/src/factory.ts @@ -4,14 +4,19 @@ import { generatedRegistry } from './generated/registry.generated'; import { registry } from './registry'; import type { ErrorClass, ErrorContext, ErrorDefinition } from './types'; +/** Optional native cause for a factory-created error; never part of context. */ +export interface ErrorFactoryOptions { + cause?: unknown; +} + /** * The callable produced for a registry entry. Codes with no context params can * be called with no arguments; codes with params require a matching context. * The `[keyof C]` tuple wrapper prevents `never` from distributing. */ export type ErrorFactory = [keyof C] extends [never] - ? (context?: Record, overrideMessage?: string) => ConstructiveError - : (context: C, overrideMessage?: string) => ConstructiveError; + ? (context?: Record, overrideMessage?: string, options?: ErrorFactoryOptions) => ConstructiveError + : (context: C, overrideMessage?: string, options?: ErrorFactoryOptions) => ConstructiveError; export type ErrorsApi = { [K in keyof R]: R[K] extends { __context: (context: infer C) => void } @@ -25,13 +30,14 @@ export type ErrorsApi = { export function makeErrorFromDefinition( def: ErrorDefinition ): ErrorFactory { - const factory = (context?: ErrorContext, overrideMessage?: string): ConstructiveError => + const factory = (context?: ErrorContext, overrideMessage?: string, options?: ErrorFactoryOptions): ConstructiveError => new ConstructiveError({ code: def.code, message: overrideMessage ?? format(def.code, context ?? {}), errorClass: def.class, http: def.http, - context + context, + ...(options && 'cause' in options ? { cause: options.cause } : {}) }); return factory as ErrorFactory; } @@ -59,14 +65,15 @@ export function makeError( messageFn: (context: C) => string, httpCode = 500, errorClass: ErrorClass = 'internal' -): (context: C, overrideMessage?: string) => ConstructiveError { - return (context: C, overrideMessage?: string) => +): (context: C, overrideMessage?: string, options?: ErrorFactoryOptions) => ConstructiveError { + return (context: C, overrideMessage?: string, options?: ErrorFactoryOptions) => new ConstructiveError({ code, message: overrideMessage ?? messageFn(context), errorClass, http: httpCode, - context + context, + ...(options && 'cause' in options ? { cause: options.cause } : {}) }); } diff --git a/packages/errors/src/parse.ts b/packages/errors/src/parse.ts index 807076af1d..5cadb05f14 100644 --- a/packages/errors/src/parse.ts +++ b/packages/errors/src/parse.ts @@ -119,6 +119,8 @@ function parseMessageCode(message: string): { code: string; args: string[] } | n * present, since that source is authoritative for the raise site (and correctly * classifies codes not yet in the registry). It falls back to `classify(code)` * (registry lookup, unknown ⇒ `internal`) only when no explicit class is given. + * `explicitClass` records the producer class actually used, and is absent when + * classification falls back to the registry or the unknown-code default. */ export function parse(error: unknown): ParsedError { if (error instanceof ConstructiveError) { @@ -126,6 +128,7 @@ export function parse(error: unknown): ParsedError { code: error.code, context: error.context ?? {}, class: error.errorClass, + ...(toErrorClass(error.errorClass) ? { explicitClass: error.errorClass } : {}), known: Boolean(getDefinition(error.code)), rawMessage: error.message, originalError: error @@ -175,6 +178,7 @@ export function parse(error: unknown): ParsedError { code, context, class: explicitClass ?? classify(code), + ...(explicitClass ? { explicitClass } : {}), known: Boolean(code && getDefinition(code)), rawMessage, sqlState, @@ -191,6 +195,8 @@ export function parse(error: unknown): ParsedError { * Codes that could not be resolved become `UNKNOWN_ERROR` (internal); a code * with no registered status is reported by {@link httpStatusFor} rather than * quietly becoming a 500. + * Newly wrapped errors retain the original input as their native cause; + * existing ConstructiveError instances are returned unchanged. */ export function toError(error: unknown, locale?: string): ConstructiveError { if (error instanceof ConstructiveError) return error; @@ -207,6 +213,7 @@ export function toError(error: unknown, locale?: string): ConstructiveError { message, errorClass: parsed.class, http: def ? def.http : httpStatusFor(code).status, - context: parsed.context + context: parsed.context, + cause: parsed.originalError }); } diff --git a/packages/errors/src/types.ts b/packages/errors/src/types.ts index a8f35f827e..98f17b4c87 100644 --- a/packages/errors/src/types.ts +++ b/packages/errors/src/types.ts @@ -58,6 +58,12 @@ export interface ParsedError { context: ErrorContext; /** Classification (`internal` when the code is unknown — fail safe). */ class: ErrorClass; + /** + * Valid producer classification actually used by parse (canonical error, + * DETAIL or GraphQL extensions). Absent when class comes from the registry + * or the unknown-code fallback. Describes the immediate input, not its cause. + */ + explicitClass?: ErrorClass; /** `true` when `code` is present in the registry. */ known: boolean; /** Best-effort raw message from the source error. */ From 698bec603e05feb0454719b5a1cd75496683287e Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 15 Sep 2026 03:42:56 +0000 Subject: [PATCH 2/2] refactor(errors): remove cause API additions --- packages/errors/README.md | 33 +- .../errors/__tests__/cause-provenance.test.ts | 406 ------------------ .../__tests__/producer-classification.test.ts | 220 ++++++++++ packages/errors/src/error.ts | 4 +- packages/errors/src/factory.ts | 21 +- packages/errors/src/parse.ts | 5 +- packages/errors/src/types.ts | 2 +- 7 files changed, 232 insertions(+), 459 deletions(-) delete mode 100644 packages/errors/__tests__/cause-provenance.test.ts create mode 100644 packages/errors/__tests__/producer-classification.test.ts diff --git a/packages/errors/README.md b/packages/errors/README.md index c708f7a2da..a0efcdc643 100644 --- a/packages/errors/README.md +++ b/packages/errors/README.md @@ -49,35 +49,6 @@ throw errors.ACCOUNT_EXISTS(); pgpm CLI codes). These override the generated entries. - Unregistered codes still `parse()` and are classified `internal` (masked). -## Causes and wrapping - -`ConstructiveError` accepts an optional `cause: unknown`. It uses native -`Error.cause`, preserving the original value and any existing cause chain. -`toError(caught)` sets the new error's cause to `caught`; an existing -`ConstructiveError` is returned unchanged, without adding a self-reference. -The cause is non-enumerable and is excluded from `toExtensions()` and ordinary -JSON serialization. Adding a cause does not change the message or context. - -Factories accept an optional third argument, `ErrorFactoryOptions`. The existing -context and override-message arguments keep their positions and behavior: - -```ts -import { errors, toError } from '@constructive-io/errors'; - -const original = new Error('upstream lookup failed'); -const wrapped = errors.MODULE_NOT_FOUND({ name: 'auth' }, undefined, { - cause: original -}); -wrapped.cause === original; // true - -const normalized = toError(original); -normalized.cause === original; // true -``` - -The same options work with `makeErrorFromDefinition()` and the factory returned -by `makeError()`. Omitting `cause` leaves the native property absent; explicitly -supplying `cause: undefined` creates a non-enumerable property with that value. - ## Producer classification `parse()` keeps its existing classification policy: a valid producer class wins, @@ -100,8 +71,8 @@ parsed.explicitClass; // undefined const adapterClass = parsed.explicitClass ?? 'internal'; ``` -This describes the **immediate input**, not the origin of its cause. A canonical -error is authoritative for its class, including errors created by registry +This describes the **immediate input**. A canonical error is authoritative for +its class, including errors created by registry factories or `toError()`. To distinguish the raw producer's class from a registry fallback, inspect `parse(caught)` before normalizing it with `toError()`. diff --git a/packages/errors/__tests__/cause-provenance.test.ts b/packages/errors/__tests__/cause-provenance.test.ts deleted file mode 100644 index 45c15ae5b9..0000000000 --- a/packages/errors/__tests__/cause-provenance.test.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { - ConstructiveError, - errors, - getDefinition, - makeError, - makeErrorFromDefinition, - parse, - toError, -} from '../src'; - -function hasOwnCause(error: Error): boolean { - return Object.prototype.hasOwnProperty.call(error, 'cause'); -} - -function expectNativeCause(error: Error, expected: unknown): void { - expect(hasOwnCause(error)).toBe(true); - expect(error.cause).toBe(expected); - expect(Object.getOwnPropertyDescriptor(error, 'cause')).toMatchObject({ - value: expected, - writable: true, - enumerable: false, - configurable: true, - }); -} - -describe('native cause provenance', () => { - it('preserves object identity and nested native causes', () => { - const nestedCause = { requestId: 'req-1' }; - const original = new Error('upstream failure', { cause: nestedCause }); - const normalized = toError(original); - const canonicalCause = { operation: 'lookup' }; - const canonical = new ConstructiveError({ - code: 'ACCOUNT_EXISTS', - message: 'account exists', - errorClass: 'public', - http: 409, - cause: canonicalCause, - }); - - expectNativeCause(normalized, original); - expect((normalized.cause as Error).cause).toBe(nestedCause); - expectNativeCause(canonical, canonicalCause); - }); - - it.each([ - ['null', null], - ['undefined', undefined], - ['string', 'a primitive failure'], - ['number', 17], - ['boolean', false], - ])('preserves a %s thrown value', (_label, thrownValue) => { - expect(toError(thrownValue).cause).toBe(thrownValue); - }); - - it('preserves identity for an arbitrary thrown object', () => { - const thrownObject = { source: 'adapter', nested: { retryable: true } }; - - expect(toError(thrownObject).cause).toBe(thrownObject); - }); - - it('distinguishes an absent cause from an explicitly supplied undefined cause', () => { - const withoutCause = new ConstructiveError({ - code: 'ACCOUNT_EXISTS', - message: 'account exists', - errorClass: 'public', - http: 409, - }); - const withUndefinedCause = new ConstructiveError({ - code: 'ACCOUNT_EXISTS', - message: 'account exists', - errorClass: 'public', - http: 409, - cause: undefined, - }); - - expect(hasOwnCause(withoutCause)).toBe(false); - expect(Object.keys(withoutCause)).not.toContain('cause'); - expectNativeCause(withUndefinedCause, undefined); - }); - - it('excludes cause from GraphQL extensions and JSON serialization', () => { - const rawCause = { secret: 'do-not-serialize', nested: { token: 'hidden' } }; - const error = new ConstructiveError({ - code: 'ACCOUNT_EXISTS', - message: 'account exists', - errorClass: 'public', - http: 409, - cause: rawCause, - }); - const extensions = error.toExtensions(); - - expect(extensions).toEqual({ code: 'ACCOUNT_EXISTS', class: 'public', http: 409 }); - expect(extensions).not.toHaveProperty('cause'); - expect(JSON.stringify(error)).not.toContain('do-not-serialize'); - expect(JSON.stringify(extensions)).not.toContain('do-not-serialize'); - }); - - it('returns a canonical error unchanged', () => { - const cause = new Error('original failure'); - const canonical = errors.ACCOUNT_EXISTS({}, 'canonical override', { cause }); - - expect(toError(canonical)).toBe(canonical); - expect(canonical.cause).toBe(cause); - expect((canonical.cause as Error).cause).toBeUndefined(); - }); -}); - -describe('factory cause options', () => { - it('supports typed, empty, generated, definition, and inline factories', () => { - const cause = { source: 'factory test' }; - const typed = errors.MODULE_NOT_FOUND({ name: 'auth' }, 'typed override', { cause }); - const empty = errors.ACCOUNT_EXISTS({}, 'empty override', { cause }); - const generated = errors.API_KEY_LIMIT_REACHED( - { resource: 'api_keys', limit: 5 }, - 'generated override', - { cause }, - ); - const definition = makeErrorFromDefinition(getDefinition('STORAGE_PROCESSING_CONFLICT')!)( - { database_id: 'db-1', file_id: 'file-1' }, - 'definition override', - { cause }, - ); - const inline = makeError<{ value: string }>( - 'STORAGE_PROCESSING_CONFLICT', - ({ value }) => `inline ${value}`, - 409, - 'public', - )({ value: 'context' }, 'inline override', { cause }); - - expect(typed).toMatchObject({ - code: 'MODULE_NOT_FOUND', - message: 'typed override', - context: { name: 'auth' }, - }); - expect(empty).toMatchObject({ - code: 'ACCOUNT_EXISTS', - message: 'empty override', - context: {}, - }); - expect(generated).toMatchObject({ - code: 'API_KEY_LIMIT_REACHED', - message: 'generated override', - context: { resource: 'api_keys', limit: 5 }, - }); - expect(definition).toMatchObject({ - code: 'STORAGE_PROCESSING_CONFLICT', - message: 'definition override', - context: { database_id: 'db-1', file_id: 'file-1' }, - }); - expect(inline).toMatchObject({ - code: 'STORAGE_PROCESSING_CONFLICT', - message: 'inline override', - context: { value: 'context' }, - }); - - for (const error of [typed, empty, generated, definition, inline]) { - expectNativeCause(error, cause); - } - }); - - it('omits the native cause property when options are omitted', () => { - const definition = makeErrorFromDefinition(getDefinition('STORAGE_PROCESSING_CONFLICT')!); - const inline = makeError<{ value: string }>( - 'STORAGE_PROCESSING_CONFLICT', - ({ value }) => value, - ); - const factoryErrors = [ - errors.MODULE_NOT_FOUND({ name: 'auth' }), - errors.ACCOUNT_EXISTS(), - errors.API_KEY_LIMIT_REACHED({}), - definition({}), - inline({ value: 'context' }), - ]; - - for (const error of factoryErrors) { - expect(hasOwnCause(error)).toBe(false); - } - }); - it('distinguishes empty factory options from an explicit undefined cause', () => { - const inline = makeError<{ value: string }>('STORAGE_PROCESSING_CONFLICT', ({ value }) => value); - expect(hasOwnCause(errors.ACCOUNT_EXISTS({}, undefined, {}))).toBe(false); - expectNativeCause(errors.ACCOUNT_EXISTS({}, undefined, { cause: undefined }), undefined); - expect(hasOwnCause(inline({ value: 'context' }, undefined, {}))).toBe(false); - expectNativeCause(inline({ value: 'context' }, undefined, { cause: undefined }), undefined); - }); -}); - -describe('producer class provenance', () => { - const unknownCode = 'ERROR_PROVENANCE_TEST_UNREGISTERED'; - - it('records valid DETAIL classes for unknown public and registered public codes overridden as internal', () => { - const unknownPublic = parse({ - message: 'detail message', - code: 'P0001', - detail: JSON.stringify({ - code: unknownCode, - context: { source: 'detail' }, - class: 'public', - }), - }); - const registeredInternal = parse({ - message: 'detail message', - code: 'P0001', - detail: JSON.stringify({ - code: 'ACCOUNT_EXISTS', - context: { source: 'detail' }, - class: 'internal', - }), - }); - - expect(unknownPublic).toMatchObject({ - code: unknownCode, - context: { source: 'detail' }, - class: 'public', - explicitClass: 'public', - known: false, - }); - expect(registeredInternal).toMatchObject({ - code: 'ACCOUNT_EXISTS', - context: { source: 'detail' }, - class: 'internal', - explicitClass: 'internal', - known: true, - }); - }); - - it('falls back to registry or internal classification for invalid and missing DETAIL classes', () => { - const cases = [ - { - code: 'STORAGE_PROCESSING_CONFLICT', - detail: { code: 'STORAGE_PROCESSING_CONFLICT', context: {}, class: 'invalid' }, - expectedClass: 'public', - known: true, - }, - { - code: 'STORAGE_PROCESSING_CONFLICT', - detail: { code: 'STORAGE_PROCESSING_CONFLICT', context: {} }, - expectedClass: 'public', - known: true, - }, - { - code: unknownCode, - detail: { code: unknownCode, context: {}, class: 'invalid' }, - expectedClass: 'internal', - known: false, - }, - { - code: unknownCode, - detail: { code: unknownCode, context: {} }, - expectedClass: 'internal', - known: false, - }, - ]; - - for (const testCase of cases) { - const result = parse({ - message: testCase.code, - code: 'P0001', - detail: JSON.stringify(testCase.detail), - }); - - expect(result.code).toBe(testCase.code); - expect(result.class).toBe(testCase.expectedClass); - expect(result.known).toBe(testCase.known); - expect(result.explicitClass).toBeUndefined(); - } - }); - - it('records direct and wrapped GraphQL producer classes', () => { - const direct = parse({ - message: 'graphql message', - extensions: { - code: 'ACCOUNT_EXISTS', - context: { source: 'graphql' }, - class: 'internal', - }, - }); - const wrapped = parse({ - errors: [ - { - message: 'wrapped graphql message', - extensions: { - code: unknownCode, - context: { source: 'wrapped' }, - class: 'public', - }, - }, - ], - }); - - expect(direct).toMatchObject({ - code: 'ACCOUNT_EXISTS', - context: { source: 'graphql' }, - class: 'internal', - explicitClass: 'internal', - known: true, - }); - expect(wrapped).toMatchObject({ - code: unknownCode, - context: { source: 'wrapped' }, - class: 'public', - explicitClass: 'public', - known: false, - }); - }); - - it('omits producer metadata when GraphQL class is missing or invalid', () => { - for (const classification of [undefined, 'invalid']) { - const result = parse({ - extensions: { code: 'STORAGE_PROCESSING_CONFLICT', class: classification }, - }); - expect(result.class).toBe('public'); - expect(result).not.toHaveProperty('explicitClass'); - } - }); - - it('keeps DETAIL precedence and does not borrow a GraphQL class when DETAIL omits one', () => { - const detailWins = parse({ - message: 'detail wins', - code: 'P0001', - detail: JSON.stringify({ - code: unknownCode, - context: { selected: 'detail' }, - class: 'public', - }), - extensions: { - code: 'ACCOUNT_EXISTS', - context: { selected: 'graphql' }, - class: 'internal', - }, - }); - const registryFallback = parse({ - message: 'detail class is absent', - code: 'P0001', - detail: JSON.stringify({ - code: 'STORAGE_PROCESSING_CONFLICT', - context: { selected: 'detail' }, - }), - extensions: { - code: 'ACCOUNT_EXISTS', - context: { selected: 'graphql' }, - class: 'internal', - }, - }); - - expect(detailWins).toMatchObject({ - code: unknownCode, - context: { selected: 'detail' }, - class: 'public', - explicitClass: 'public', - known: false, - }); - expect(registryFallback).toMatchObject({ - code: 'STORAGE_PROCESSING_CONFLICT', - context: { selected: 'detail' }, - class: 'public', - known: true, - }); - expect(registryFallback.explicitClass).toBeUndefined(); - }); - - it('treats a canonical instance as the immediate classified producer', () => { - const canonical = new ConstructiveError({ - code: 'ACCOUNT_EXISTS', - message: 'canonical message', - errorClass: 'internal', - http: 500, - context: { source: 'canonical' }, - }); - - const result = parse(canonical); - - expect(result).toMatchObject({ - code: 'ACCOUNT_EXISTS', - context: { source: 'canonical' }, - class: 'internal', - explicitClass: 'internal', - known: true, - rawMessage: 'canonical message', - originalError: canonical, - }); - }); - - it('marks toError output with its own immediate class metadata', () => { - const raw = { - message: 'raw producer message', - code: 'P0001', - detail: JSON.stringify({ code: 'STORAGE_PROCESSING_CONFLICT', context: { source: 'raw' } }), - }; - const rawParsed = parse(raw); - const normalized = toError(raw); - const normalizedParsed = parse(normalized); - - expect(rawParsed.class).toBe('public'); - expect(rawParsed.explicitClass).toBeUndefined(); - expect(normalized.cause).toBe(raw); - expect(normalizedParsed).toMatchObject({ - code: 'STORAGE_PROCESSING_CONFLICT', - context: { source: 'raw' }, - class: 'public', - explicitClass: 'public', - known: true, - originalError: normalized, - }); - }); -}); diff --git a/packages/errors/__tests__/producer-classification.test.ts b/packages/errors/__tests__/producer-classification.test.ts new file mode 100644 index 0000000000..48e2cd80dd --- /dev/null +++ b/packages/errors/__tests__/producer-classification.test.ts @@ -0,0 +1,220 @@ +import { ConstructiveError, parse, toError } from '../src'; + +describe('producer class provenance', () => { + const unknownCode = 'ERROR_PROVENANCE_TEST_UNREGISTERED'; + + it('records valid DETAIL classes for unknown public and registered public codes overridden as internal', () => { + const unknownPublic = parse({ + message: 'detail message', + code: 'P0001', + detail: JSON.stringify({ + code: unknownCode, + context: { source: 'detail' }, + class: 'public', + }), + }); + const registeredInternal = parse({ + message: 'detail message', + code: 'P0001', + detail: JSON.stringify({ + code: 'ACCOUNT_EXISTS', + context: { source: 'detail' }, + class: 'internal', + }), + }); + + expect(unknownPublic).toMatchObject({ + code: unknownCode, + context: { source: 'detail' }, + class: 'public', + explicitClass: 'public', + known: false, + }); + expect(registeredInternal).toMatchObject({ + code: 'ACCOUNT_EXISTS', + context: { source: 'detail' }, + class: 'internal', + explicitClass: 'internal', + known: true, + }); + }); + + it('falls back to registry or internal classification for invalid and missing DETAIL classes', () => { + const cases = [ + { + code: 'STORAGE_PROCESSING_CONFLICT', + detail: { code: 'STORAGE_PROCESSING_CONFLICT', context: {}, class: 'invalid' }, + expectedClass: 'public', + known: true, + }, + { + code: 'STORAGE_PROCESSING_CONFLICT', + detail: { code: 'STORAGE_PROCESSING_CONFLICT', context: {} }, + expectedClass: 'public', + known: true, + }, + { + code: unknownCode, + detail: { code: unknownCode, context: {}, class: 'invalid' }, + expectedClass: 'internal', + known: false, + }, + { + code: unknownCode, + detail: { code: unknownCode, context: {} }, + expectedClass: 'internal', + known: false, + }, + ]; + + for (const testCase of cases) { + const result = parse({ + message: testCase.code, + code: 'P0001', + detail: JSON.stringify(testCase.detail), + }); + + expect(result.code).toBe(testCase.code); + expect(result.class).toBe(testCase.expectedClass); + expect(result.known).toBe(testCase.known); + expect(result.explicitClass).toBeUndefined(); + } + }); + + it('records direct and wrapped GraphQL producer classes', () => { + const direct = parse({ + message: 'graphql message', + extensions: { + code: 'ACCOUNT_EXISTS', + context: { source: 'graphql' }, + class: 'internal', + }, + }); + const wrapped = parse({ + errors: [ + { + message: 'wrapped graphql message', + extensions: { + code: unknownCode, + context: { source: 'wrapped' }, + class: 'public', + }, + }, + ], + }); + + expect(direct).toMatchObject({ + code: 'ACCOUNT_EXISTS', + context: { source: 'graphql' }, + class: 'internal', + explicitClass: 'internal', + known: true, + }); + expect(wrapped).toMatchObject({ + code: unknownCode, + context: { source: 'wrapped' }, + class: 'public', + explicitClass: 'public', + known: false, + }); + }); + + it('omits producer metadata when GraphQL class is missing or invalid', () => { + for (const classification of [undefined, 'invalid']) { + const result = parse({ + extensions: { code: 'STORAGE_PROCESSING_CONFLICT', class: classification }, + }); + expect(result.class).toBe('public'); + expect(result).not.toHaveProperty('explicitClass'); + } + }); + + it('keeps DETAIL precedence and does not borrow a GraphQL class when DETAIL omits one', () => { + const detailWins = parse({ + message: 'detail wins', + code: 'P0001', + detail: JSON.stringify({ + code: unknownCode, + context: { selected: 'detail' }, + class: 'public', + }), + extensions: { + code: 'ACCOUNT_EXISTS', + context: { selected: 'graphql' }, + class: 'internal', + }, + }); + const registryFallback = parse({ + message: 'detail class is absent', + code: 'P0001', + detail: JSON.stringify({ + code: 'STORAGE_PROCESSING_CONFLICT', + context: { selected: 'detail' }, + }), + extensions: { + code: 'ACCOUNT_EXISTS', + context: { selected: 'graphql' }, + class: 'internal', + }, + }); + + expect(detailWins).toMatchObject({ + code: unknownCode, + context: { selected: 'detail' }, + class: 'public', + explicitClass: 'public', + known: false, + }); + expect(registryFallback).toMatchObject({ + code: 'STORAGE_PROCESSING_CONFLICT', + context: { selected: 'detail' }, + class: 'public', + known: true, + }); + expect(registryFallback.explicitClass).toBeUndefined(); + }); + + it('treats a canonical instance as the immediate classified producer', () => { + const canonical = new ConstructiveError({ + code: 'ACCOUNT_EXISTS', + message: 'canonical message', + errorClass: 'internal', + http: 500, + context: { source: 'canonical' }, + }); + + const result = parse(canonical); + + expect(result).toMatchObject({ + code: 'ACCOUNT_EXISTS', + context: { source: 'canonical' }, + class: 'internal', + explicitClass: 'internal', + known: true, + rawMessage: 'canonical message', + originalError: canonical, + }); + }); + + it('marks toError output with its own immediate class metadata', () => { + const raw = { + message: 'raw producer message', + code: 'P0001', + detail: JSON.stringify({ code: 'STORAGE_PROCESSING_CONFLICT', context: { source: 'raw' } }), + }; + const rawParsed = parse(raw); + const normalized = toError(raw); + const normalizedParsed = parse(normalized); + + expect(rawParsed.class).toBe('public'); + expect(rawParsed.explicitClass).toBeUndefined(); + expect(normalizedParsed).toMatchObject({ + code: 'STORAGE_PROCESSING_CONFLICT', + context: { source: 'raw' }, + class: 'public', + explicitClass: 'public', + known: true, + originalError: normalized, + }); + }); +}); diff --git a/packages/errors/src/error.ts b/packages/errors/src/error.ts index e22ebab556..98f9269f15 100644 --- a/packages/errors/src/error.ts +++ b/packages/errors/src/error.ts @@ -6,8 +6,6 @@ export interface ConstructiveErrorArgs { errorClass: ErrorClass; http: number; context?: ErrorContext; - /** Original failure, retained as a native non-enumerable Error.cause. */ - cause?: unknown; } /** @@ -24,7 +22,7 @@ export class ConstructiveError extends Error { readonly context?: ErrorContext; constructor(args: ConstructiveErrorArgs) { - super(args.message, 'cause' in args ? { cause: args.cause } : undefined); + super(args.message); this.name = 'ConstructiveError'; this.code = args.code; this.errorClass = args.errorClass; diff --git a/packages/errors/src/factory.ts b/packages/errors/src/factory.ts index 7747b517da..8d34d01c95 100644 --- a/packages/errors/src/factory.ts +++ b/packages/errors/src/factory.ts @@ -4,19 +4,14 @@ import { generatedRegistry } from './generated/registry.generated'; import { registry } from './registry'; import type { ErrorClass, ErrorContext, ErrorDefinition } from './types'; -/** Optional native cause for a factory-created error; never part of context. */ -export interface ErrorFactoryOptions { - cause?: unknown; -} - /** * The callable produced for a registry entry. Codes with no context params can * be called with no arguments; codes with params require a matching context. * The `[keyof C]` tuple wrapper prevents `never` from distributing. */ export type ErrorFactory = [keyof C] extends [never] - ? (context?: Record, overrideMessage?: string, options?: ErrorFactoryOptions) => ConstructiveError - : (context: C, overrideMessage?: string, options?: ErrorFactoryOptions) => ConstructiveError; + ? (context?: Record, overrideMessage?: string) => ConstructiveError + : (context: C, overrideMessage?: string) => ConstructiveError; export type ErrorsApi = { [K in keyof R]: R[K] extends { __context: (context: infer C) => void } @@ -30,14 +25,13 @@ export type ErrorsApi = { export function makeErrorFromDefinition( def: ErrorDefinition ): ErrorFactory { - const factory = (context?: ErrorContext, overrideMessage?: string, options?: ErrorFactoryOptions): ConstructiveError => + const factory = (context?: ErrorContext, overrideMessage?: string): ConstructiveError => new ConstructiveError({ code: def.code, message: overrideMessage ?? format(def.code, context ?? {}), errorClass: def.class, http: def.http, - context, - ...(options && 'cause' in options ? { cause: options.cause } : {}) + context }); return factory as ErrorFactory; } @@ -65,15 +59,14 @@ export function makeError( messageFn: (context: C) => string, httpCode = 500, errorClass: ErrorClass = 'internal' -): (context: C, overrideMessage?: string, options?: ErrorFactoryOptions) => ConstructiveError { - return (context: C, overrideMessage?: string, options?: ErrorFactoryOptions) => +): (context: C, overrideMessage?: string) => ConstructiveError { + return (context: C, overrideMessage?: string) => new ConstructiveError({ code, message: overrideMessage ?? messageFn(context), errorClass, http: httpCode, - context, - ...(options && 'cause' in options ? { cause: options.cause } : {}) + context }); } diff --git a/packages/errors/src/parse.ts b/packages/errors/src/parse.ts index 5cadb05f14..c0149316f4 100644 --- a/packages/errors/src/parse.ts +++ b/packages/errors/src/parse.ts @@ -195,8 +195,6 @@ export function parse(error: unknown): ParsedError { * Codes that could not be resolved become `UNKNOWN_ERROR` (internal); a code * with no registered status is reported by {@link httpStatusFor} rather than * quietly becoming a 500. - * Newly wrapped errors retain the original input as their native cause; - * existing ConstructiveError instances are returned unchanged. */ export function toError(error: unknown, locale?: string): ConstructiveError { if (error instanceof ConstructiveError) return error; @@ -213,7 +211,6 @@ export function toError(error: unknown, locale?: string): ConstructiveError { message, errorClass: parsed.class, http: def ? def.http : httpStatusFor(code).status, - context: parsed.context, - cause: parsed.originalError + context: parsed.context }); } diff --git a/packages/errors/src/types.ts b/packages/errors/src/types.ts index 98f17b4c87..e36372e767 100644 --- a/packages/errors/src/types.ts +++ b/packages/errors/src/types.ts @@ -61,7 +61,7 @@ export interface ParsedError { /** * Valid producer classification actually used by parse (canonical error, * DETAIL or GraphQL extensions). Absent when class comes from the registry - * or the unknown-code fallback. Describes the immediate input, not its cause. + * or the unknown-code fallback. Describes the immediate input before any further normalization. */ explicitClass?: ErrorClass; /** `true` when `code` is present in the registry. */