Skip to content
Open
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
29 changes: 28 additions & 1 deletion packages/errors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
220 changes: 220 additions & 0 deletions packages/errors/__tests__/producer-classification.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
4 changes: 4 additions & 0 deletions packages/errors/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,16 @@ 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) {
return {
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
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions packages/errors/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading