diff --git a/packages/errors/README.md b/packages/errors/README.md index 4668324e5..a0efcdc64 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,33 @@ throw errors.ACCOUNT_EXISTS(); pgpm CLI codes). These override the generated entries. - Unregistered codes still `parse()` and are classified `internal` (masked). +## 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**. 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__/producer-classification.test.ts b/packages/errors/__tests__/producer-classification.test.ts new file mode 100644 index 000000000..48e2cd80d --- /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/parse.ts b/packages/errors/src/parse.ts index 807076af1..c0149316f 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, diff --git a/packages/errors/src/types.ts b/packages/errors/src/types.ts index a8f35f827..e36372e76 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 before any further normalization. + */ + explicitClass?: ErrorClass; /** `true` when `code` is present in the registry. */ known: boolean; /** Best-effort raw message from the source error. */