@devp0nt/error0 1.0.0-next.5 → 1.0.0-next.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/index.cjs +614 -0
- package/dist/cjs/index.cjs.map +1 -0
- package/dist/cjs/index.d.cts +336 -414
- package/dist/cjs/plugins/cause-serialize.cjs +41 -0
- package/dist/cjs/plugins/cause-serialize.cjs.map +1 -0
- package/dist/cjs/plugins/cause-serialize.d.cts +7 -0
- package/dist/cjs/plugins/code.cjs +38 -0
- package/dist/cjs/plugins/code.cjs.map +1 -0
- package/dist/cjs/plugins/code.d.cts +7 -0
- package/dist/cjs/plugins/expected.cjs +54 -0
- package/dist/cjs/plugins/expected.cjs.map +1 -0
- package/dist/cjs/plugins/expected.d.cts +36 -0
- package/dist/cjs/plugins/message-merge.cjs +39 -0
- package/dist/cjs/plugins/message-merge.cjs.map +1 -0
- package/dist/cjs/plugins/message-merge.d.cts +8 -0
- package/dist/cjs/plugins/meta.cjs +78 -0
- package/dist/cjs/plugins/meta.cjs.map +1 -0
- package/dist/cjs/plugins/meta.d.cts +7 -0
- package/dist/cjs/plugins/stack-merge.cjs +42 -0
- package/dist/cjs/plugins/stack-merge.cjs.map +1 -0
- package/dist/cjs/plugins/stack-merge.d.cts +8 -0
- package/dist/cjs/plugins/status.cjs +54 -0
- package/dist/cjs/plugins/status.cjs.map +1 -0
- package/dist/cjs/plugins/status.d.cts +8 -0
- package/dist/cjs/plugins/tags.cjs +73 -0
- package/dist/cjs/plugins/tags.cjs.map +1 -0
- package/dist/cjs/plugins/tags.d.cts +12 -0
- package/dist/esm/index.d.ts +336 -414
- package/dist/esm/index.js +530 -341
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/plugins/cause-serialize.d.ts +7 -0
- package/dist/esm/plugins/cause-serialize.js +17 -0
- package/dist/esm/plugins/cause-serialize.js.map +1 -0
- package/dist/esm/plugins/code.d.ts +7 -0
- package/dist/esm/plugins/code.js +14 -0
- package/dist/esm/plugins/code.js.map +1 -0
- package/dist/esm/plugins/expected.d.ts +36 -0
- package/dist/esm/plugins/expected.js +30 -0
- package/dist/esm/plugins/expected.js.map +1 -0
- package/dist/esm/plugins/message-merge.d.ts +8 -0
- package/dist/esm/plugins/message-merge.js +15 -0
- package/dist/esm/plugins/message-merge.js.map +1 -0
- package/dist/esm/plugins/meta.d.ts +7 -0
- package/dist/esm/plugins/meta.js +54 -0
- package/dist/esm/plugins/meta.js.map +1 -0
- package/dist/esm/plugins/stack-merge.d.ts +8 -0
- package/dist/esm/plugins/stack-merge.js +18 -0
- package/dist/esm/plugins/stack-merge.js.map +1 -0
- package/dist/esm/plugins/status.d.ts +8 -0
- package/dist/esm/plugins/status.js +30 -0
- package/dist/esm/plugins/status.js.map +1 -0
- package/dist/esm/plugins/tags.d.ts +12 -0
- package/dist/esm/plugins/tags.js +49 -0
- package/dist/esm/plugins/tags.js.map +1 -0
- package/package.json +53 -23
- package/src/index.test.ts +689 -452
- package/src/index.ts +1163 -502
- package/src/plugins/cause-serialize.test.ts +53 -0
- package/src/plugins/cause-serialize.ts +15 -0
- package/src/plugins/code.test.ts +27 -0
- package/src/plugins/code.ts +12 -0
- package/src/plugins/expected.test.ts +47 -0
- package/src/plugins/expected.ts +31 -0
- package/src/plugins/message-merge.test.ts +32 -0
- package/src/plugins/message-merge.ts +19 -0
- package/src/plugins/meta.test.ts +32 -0
- package/src/plugins/meta.ts +59 -0
- package/src/plugins/stack-merge.test.ts +64 -0
- package/src/plugins/stack-merge.ts +20 -0
- package/src/plugins/status.test.ts +54 -0
- package/src/plugins/status.ts +29 -0
- package/src/plugins/tags.test.ts +74 -0
- package/src/plugins/tags.ts +51 -0
- package/dist/cjs/index.js +0 -435
- package/dist/cjs/index.js.map +0 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { Error0 } from '../index.js'
|
|
3
|
+
import { causeSerializePlugin } from './cause-serialize.js'
|
|
4
|
+
|
|
5
|
+
describe('causeSerializePlugin', () => {
|
|
6
|
+
const statusPlugin = Error0.plugin().use('prop', 'status', {
|
|
7
|
+
init: (input: number) => input,
|
|
8
|
+
resolve: ({ flow }) => flow.find((value) => typeof value === 'number'),
|
|
9
|
+
serialize: ({ resolved }) => resolved,
|
|
10
|
+
deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const codes = ['NOT_FOUND', 'BAD_REQUEST', 'UNAUTHORIZED'] as const
|
|
14
|
+
type Code = (typeof codes)[number]
|
|
15
|
+
const codePlugin = Error0.plugin().use('prop', 'code', {
|
|
16
|
+
init: (input: Code) => input,
|
|
17
|
+
resolve: ({ flow }) => flow.find((value) => typeof value === 'string' && codes.includes(value)),
|
|
18
|
+
serialize: ({ resolved, isPublic }) => (isPublic ? undefined : resolved),
|
|
19
|
+
deserialize: ({ value }) =>
|
|
20
|
+
typeof value === 'string' && codes.includes(value as Code) ? (value as Code) : undefined,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('serializes and deserializes nested Error0 causes', () => {
|
|
24
|
+
const AppError = Error0.use(statusPlugin)
|
|
25
|
+
.use(codePlugin)
|
|
26
|
+
.use(causeSerializePlugin({ hideWhenPublic: false }))
|
|
27
|
+
const deepCauseError = new AppError('deep cause')
|
|
28
|
+
const causeError = new AppError('cause', { status: 409, code: 'NOT_FOUND', cause: deepCauseError })
|
|
29
|
+
const error = new AppError('root', { status: 500, cause: causeError })
|
|
30
|
+
|
|
31
|
+
const json = AppError.serialize(error, false)
|
|
32
|
+
expect(typeof json.cause).toBe('object')
|
|
33
|
+
expect((json.cause as any).message).toBe('cause')
|
|
34
|
+
expect((json.cause as any).status).toBe(409)
|
|
35
|
+
expect((json.cause as any).code).toBe('NOT_FOUND')
|
|
36
|
+
expect((json.cause as any).cause).toBeDefined()
|
|
37
|
+
expect((json.cause as any).cause.message).toBe('deep cause')
|
|
38
|
+
expect((json.cause as any).cause.status).toBe(undefined)
|
|
39
|
+
expect((json.cause as any).cause.code).toBe(undefined)
|
|
40
|
+
expect((json.cause as any).cause.cause).toBeUndefined()
|
|
41
|
+
|
|
42
|
+
const recreated = AppError.from(json)
|
|
43
|
+
expect(recreated).toBeInstanceOf(AppError)
|
|
44
|
+
expect(recreated.cause).toBeInstanceOf(AppError)
|
|
45
|
+
expect((recreated.cause as any).status).toBe(409)
|
|
46
|
+
expect((recreated.cause as any).code).toBe('NOT_FOUND')
|
|
47
|
+
expect((recreated.cause as any).cause).toBeInstanceOf(AppError)
|
|
48
|
+
expect((recreated.cause as any).cause.message).toBe('deep cause')
|
|
49
|
+
expect((recreated.cause as any).cause.status).toBe(undefined)
|
|
50
|
+
expect((recreated.cause as any).cause.code).toBe(undefined)
|
|
51
|
+
expect((recreated.cause as any).cause.cause).toBeUndefined()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Error0 } from '../index.js'
|
|
2
|
+
|
|
3
|
+
export const causeSerializePlugin = ({ hideWhenPublic = true }: { hideWhenPublic?: boolean } = {}) =>
|
|
4
|
+
Error0.plugin().cause({
|
|
5
|
+
serialize: ({ value, error, isPublic }) => {
|
|
6
|
+
if (hideWhenPublic && isPublic) {
|
|
7
|
+
return undefined
|
|
8
|
+
}
|
|
9
|
+
const ctor = error.constructor as typeof Error0
|
|
10
|
+
if (ctor.is(value)) {
|
|
11
|
+
return ctor.serialize(value, isPublic)
|
|
12
|
+
}
|
|
13
|
+
return undefined
|
|
14
|
+
},
|
|
15
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from 'bun:test'
|
|
2
|
+
import { Error0 } from '../index.js'
|
|
3
|
+
import { codePlugin } from './code.js'
|
|
4
|
+
|
|
5
|
+
describe('codePlugin', () => {
|
|
6
|
+
const codes = ['NOT_FOUND', 'BAD_REQUEST', 'UNAUTHORIZED'] as const
|
|
7
|
+
|
|
8
|
+
it('serializes and deserializes allowed codes', () => {
|
|
9
|
+
const AppError = Error0.use(codePlugin({ codes: [...codes] }))
|
|
10
|
+
const error = new AppError('test', { code: 'NOT_FOUND' })
|
|
11
|
+
|
|
12
|
+
expect(error.code).toBe('NOT_FOUND')
|
|
13
|
+
expectTypeOf<typeof error.code>().toEqualTypeOf<'NOT_FOUND' | 'BAD_REQUEST' | 'UNAUTHORIZED' | undefined>()
|
|
14
|
+
|
|
15
|
+
const json = AppError.serialize(error, false)
|
|
16
|
+
expect(json.code).toBe('NOT_FOUND')
|
|
17
|
+
|
|
18
|
+
const recreated = AppError.from(json)
|
|
19
|
+
expect(recreated.code).toBe('NOT_FOUND')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('ignores code values outside the allowed list', () => {
|
|
23
|
+
const AppError = Error0.use(codePlugin({ codes: [...codes] }))
|
|
24
|
+
const recreated = AppError.from({ message: 'test', code: 'SOMETHING_ELSE' })
|
|
25
|
+
expect(recreated.code).toBeUndefined()
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Error0 } from '../index.js'
|
|
2
|
+
|
|
3
|
+
export const codePlugin = <TCode extends string>({ codes }: { codes?: TCode[] } = {}) => {
|
|
4
|
+
const isCode = (value: unknown): value is TCode =>
|
|
5
|
+
typeof value === 'string' && (!codes || codes.includes(value as TCode))
|
|
6
|
+
return Error0.plugin().prop('code', {
|
|
7
|
+
init: (code: TCode) => code,
|
|
8
|
+
resolve: ({ flow }) => flow.find(Boolean),
|
|
9
|
+
serialize: ({ resolved, isPublic }) => resolved,
|
|
10
|
+
deserialize: ({ value, record }) => (isCode(value) ? value : undefined),
|
|
11
|
+
})
|
|
12
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { Error0 } from '../index.js'
|
|
3
|
+
import { expectedPlugin } from './expected.js'
|
|
4
|
+
|
|
5
|
+
describe('expectedPlugin', () => {
|
|
6
|
+
const statusPlugin = Error0.plugin().use('prop', 'status', {
|
|
7
|
+
init: (input: number) => input,
|
|
8
|
+
resolve: ({ flow }) => flow.find((value) => typeof value === 'number'),
|
|
9
|
+
serialize: ({ resolved }) => resolved,
|
|
10
|
+
deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('can be used to control error tracker behavior', () => {
|
|
14
|
+
const AppError = Error0.use(statusPlugin).use(expectedPlugin({ hideWhenPublic: false }))
|
|
15
|
+
const errorExpected = new AppError('test', { status: 400, expected: true })
|
|
16
|
+
const errorUnexpected = new AppError('test', { status: 400, expected: false })
|
|
17
|
+
const usualError = new Error('test')
|
|
18
|
+
const errorFromUsualError = AppError.from(usualError)
|
|
19
|
+
const errorWithExpectedErrorAsCause = new AppError('test', { status: 400, cause: errorExpected })
|
|
20
|
+
const errorWithUnexpectedErrorAsCause = new AppError('test', { status: 400, cause: errorUnexpected })
|
|
21
|
+
expect(errorExpected.expected).toBe(true)
|
|
22
|
+
expect(errorUnexpected.expected).toBe(false)
|
|
23
|
+
expect(AppError.isExpected(usualError)).toBe(false)
|
|
24
|
+
expect(errorFromUsualError.expected).toBe(false)
|
|
25
|
+
expect(errorFromUsualError.isExpected()).toBe(false)
|
|
26
|
+
expect(errorWithExpectedErrorAsCause.expected).toBe(true)
|
|
27
|
+
expect(errorWithExpectedErrorAsCause.isExpected()).toBe(true)
|
|
28
|
+
expect(errorWithUnexpectedErrorAsCause.expected).toBe(false)
|
|
29
|
+
expect(errorWithUnexpectedErrorAsCause.isExpected()).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('resolves to false when any cause has false', () => {
|
|
33
|
+
const AppError = Error0.use(expectedPlugin({ hideWhenPublic: false }))
|
|
34
|
+
const root = new AppError('root', { expected: true })
|
|
35
|
+
const middle = new AppError('middle', { expected: false, cause: root })
|
|
36
|
+
const leaf = new AppError('leaf', { expected: false, cause: middle })
|
|
37
|
+
expect(leaf.expected).toBe(false)
|
|
38
|
+
expect(leaf.isExpected()).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('treats undefined expected as unexpected', () => {
|
|
42
|
+
const AppError = Error0.use(expectedPlugin({ hideWhenPublic: false }))
|
|
43
|
+
const error = new AppError('without expected')
|
|
44
|
+
expect(error.expected).toBe(false)
|
|
45
|
+
expect(error.isExpected()).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Error0 } from '../index.js'
|
|
2
|
+
|
|
3
|
+
const isExpected = (flow: unknown[]) => {
|
|
4
|
+
let expected = false
|
|
5
|
+
for (const value of flow) {
|
|
6
|
+
if (value === false) {
|
|
7
|
+
return false
|
|
8
|
+
}
|
|
9
|
+
if (value === true) {
|
|
10
|
+
expected = true
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return expected
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const expectedPlugin = ({ hideWhenPublic = true }: { hideWhenPublic?: boolean } = {}) =>
|
|
17
|
+
Error0.plugin()
|
|
18
|
+
.prop('expected', {
|
|
19
|
+
init: (input: boolean) => input,
|
|
20
|
+
resolve: ({ flow }) => isExpected(flow),
|
|
21
|
+
serialize: ({ resolved, isPublic }) => {
|
|
22
|
+
if (hideWhenPublic && isPublic) {
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
return resolved
|
|
26
|
+
},
|
|
27
|
+
deserialize: ({ value }) => (typeof value === 'boolean' ? value : undefined),
|
|
28
|
+
})
|
|
29
|
+
.method('isExpected', (error) => {
|
|
30
|
+
return isExpected(error.flow('expected'))
|
|
31
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { Error0 } from '../index.js'
|
|
3
|
+
import { messageMergePlugin } from './message-merge.js'
|
|
4
|
+
|
|
5
|
+
describe('messageMergePlugin', () => {
|
|
6
|
+
const statusPlugin = Error0.plugin().use('prop', 'status', {
|
|
7
|
+
init: (input: number) => input,
|
|
8
|
+
resolve: ({ flow }) => flow.find((value) => typeof value === 'number'),
|
|
9
|
+
serialize: ({ resolved }) => resolved,
|
|
10
|
+
deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const codes = ['NOT_FOUND', 'BAD_REQUEST', 'UNAUTHORIZED'] as const
|
|
14
|
+
type Code = (typeof codes)[number]
|
|
15
|
+
const codePlugin = Error0.plugin().use('prop', 'code', {
|
|
16
|
+
init: (input: Code) => input,
|
|
17
|
+
resolve: ({ flow }) => flow.find((value) => typeof value === 'string' && codes.includes(value)),
|
|
18
|
+
serialize: ({ resolved, isPublic }) => (isPublic ? undefined : resolved),
|
|
19
|
+
deserialize: ({ value }) =>
|
|
20
|
+
typeof value === 'string' && codes.includes(value as Code) ? (value as Code) : undefined,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('can merge message across causes in one serialized value', () => {
|
|
24
|
+
const AppError = Error0.use(statusPlugin).use(codePlugin).use(messageMergePlugin())
|
|
25
|
+
const error1 = new AppError('test1', { status: 400, code: 'NOT_FOUND' })
|
|
26
|
+
const error2 = new AppError('test2', { status: 401, cause: error1 })
|
|
27
|
+
expect(error1.message).toBe('test1')
|
|
28
|
+
expect(error2.message).toBe('test2')
|
|
29
|
+
expect(error1.serialize().message).toBe('test1')
|
|
30
|
+
expect(error2.serialize().message).toBe('test2: test1')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Error0 } from '../index.js'
|
|
2
|
+
|
|
3
|
+
export const messageMergePlugin = ({
|
|
4
|
+
delimiter = ': ',
|
|
5
|
+
fallback = 'Unknown error',
|
|
6
|
+
}: { delimiter?: string; fallback?: string } = {}) =>
|
|
7
|
+
Error0.plugin().use('message', {
|
|
8
|
+
serialize: ({ error }) => {
|
|
9
|
+
return (
|
|
10
|
+
error
|
|
11
|
+
.causes()
|
|
12
|
+
.map((cause) => {
|
|
13
|
+
return cause instanceof Error ? cause.message : undefined
|
|
14
|
+
})
|
|
15
|
+
.filter((value): value is string => typeof value === 'string')
|
|
16
|
+
.join(delimiter) || fallback
|
|
17
|
+
)
|
|
18
|
+
},
|
|
19
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { Error0 } from '../index.js'
|
|
3
|
+
import { metaPlugin } from './meta.js'
|
|
4
|
+
|
|
5
|
+
describe('metaPlugin', () => {
|
|
6
|
+
it('merges meta across causes into current error', () => {
|
|
7
|
+
const AppError = Error0.use(metaPlugin())
|
|
8
|
+
const root = new AppError('root', { meta: { requestId: 'r1', source: 'db' } })
|
|
9
|
+
const leaf = new AppError('leaf', { meta: { route: '/ideas', source: 'api' }, cause: root })
|
|
10
|
+
expect(leaf.resolve().meta).toEqual({
|
|
11
|
+
requestId: 'r1',
|
|
12
|
+
source: 'api',
|
|
13
|
+
route: '/ideas',
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('serializes meta only for private output and keeps json-safe values', () => {
|
|
18
|
+
const AppError = Error0.use(metaPlugin())
|
|
19
|
+
const error = new AppError('test', {
|
|
20
|
+
meta: {
|
|
21
|
+
ok: true,
|
|
22
|
+
nested: { id: 1 },
|
|
23
|
+
skip: () => 'x',
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
expect(AppError.serialize(error, false).meta).toEqual({
|
|
27
|
+
ok: true,
|
|
28
|
+
nested: { id: 1 },
|
|
29
|
+
})
|
|
30
|
+
expect('meta' in AppError.serialize(error, true)).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Error0 } from '../index.js'
|
|
2
|
+
|
|
3
|
+
type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
|
|
4
|
+
|
|
5
|
+
const toJsonSafe = (input: unknown): Json | undefined => {
|
|
6
|
+
if (input === null) {
|
|
7
|
+
return null
|
|
8
|
+
}
|
|
9
|
+
if (typeof input === 'string' || typeof input === 'number' || typeof input === 'boolean') {
|
|
10
|
+
return input
|
|
11
|
+
}
|
|
12
|
+
if (Array.isArray(input)) {
|
|
13
|
+
return input.map((value) => toJsonSafe(value)) as Json[]
|
|
14
|
+
}
|
|
15
|
+
if (typeof input === 'object') {
|
|
16
|
+
const output: Record<string, Json> = {}
|
|
17
|
+
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
|
18
|
+
const jsonValue = toJsonSafe(value)
|
|
19
|
+
if (jsonValue !== undefined) {
|
|
20
|
+
output[key] = jsonValue
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return output
|
|
24
|
+
}
|
|
25
|
+
return undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const isMetaRecord = (value: unknown): value is Record<string, unknown> =>
|
|
29
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
30
|
+
|
|
31
|
+
export const metaPlugin = ({ hideWhenPublic = true }: { hideWhenPublic?: boolean } = {}) =>
|
|
32
|
+
Error0.plugin().prop('meta', {
|
|
33
|
+
init: (input: Record<string, unknown>) => input,
|
|
34
|
+
resolve: ({ flow }) => {
|
|
35
|
+
const values = flow.filter(isMetaRecord)
|
|
36
|
+
if (values.length === 0) {
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Merge cause meta into the current error; nearer errors win on conflicts.
|
|
41
|
+
const merged: Record<string, unknown> = {}
|
|
42
|
+
for (const value of [...values].reverse()) {
|
|
43
|
+
Object.assign(merged, value)
|
|
44
|
+
}
|
|
45
|
+
return merged
|
|
46
|
+
},
|
|
47
|
+
serialize: ({ resolved, isPublic }) => {
|
|
48
|
+
if (hideWhenPublic && isPublic) {
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
return toJsonSafe(resolved)
|
|
52
|
+
},
|
|
53
|
+
deserialize: ({ value }) => {
|
|
54
|
+
if (!isMetaRecord(value)) {
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
return value
|
|
58
|
+
},
|
|
59
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { Error0 } from '../index.js'
|
|
3
|
+
import { stackMergePlugin } from './stack-merge.js'
|
|
4
|
+
|
|
5
|
+
const fixStack = (stack: string | undefined) => {
|
|
6
|
+
if (!stack) {
|
|
7
|
+
return stack
|
|
8
|
+
}
|
|
9
|
+
const lines = stack.split('\n')
|
|
10
|
+
const fixedLines = lines.map((line) => {
|
|
11
|
+
const withoutPath = line.replace(/\(.*\)$/, '(...)')
|
|
12
|
+
return withoutPath
|
|
13
|
+
})
|
|
14
|
+
return fixedLines.join('\n')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('stackMergePlugin', () => {
|
|
18
|
+
const statusPlugin = Error0.plugin().use('prop', 'status', {
|
|
19
|
+
init: (input: number) => input,
|
|
20
|
+
resolve: ({ flow }) => flow.find((value) => typeof value === 'number'),
|
|
21
|
+
serialize: ({ resolved }) => resolved,
|
|
22
|
+
deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const codes = ['NOT_FOUND', 'BAD_REQUEST', 'UNAUTHORIZED'] as const
|
|
26
|
+
type Code = (typeof codes)[number]
|
|
27
|
+
const codePlugin = Error0.plugin().use('prop', 'code', {
|
|
28
|
+
init: (input: Code) => input,
|
|
29
|
+
resolve: ({ flow }) => flow.find((value) => typeof value === 'string' && codes.includes(value)),
|
|
30
|
+
serialize: ({ resolved, isPublic }) => (isPublic ? undefined : resolved),
|
|
31
|
+
deserialize: ({ value }) =>
|
|
32
|
+
typeof value === 'string' && codes.includes(value as Code) ? (value as Code) : undefined,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('can merge stack across causes in one serialized value', () => {
|
|
36
|
+
const AppError = Error0.use(statusPlugin).use(codePlugin).use(stackMergePlugin())
|
|
37
|
+
const error1 = new AppError('test1', { status: 400, code: 'NOT_FOUND' })
|
|
38
|
+
const error2 = new AppError('test2', { status: 401, cause: error1 })
|
|
39
|
+
const mergedStack1 = error1.serialize(false).stack as string
|
|
40
|
+
const mergedStack2 = error2.serialize(false).stack as string
|
|
41
|
+
const mergedStack2Public = error2.serialize(true).stack as string | undefined
|
|
42
|
+
expect(mergedStack1).toContain('Error0: test1')
|
|
43
|
+
expect(mergedStack2).toContain('Error0: test2')
|
|
44
|
+
expect(mergedStack2).toContain('Error0: test1')
|
|
45
|
+
expect(fixStack(mergedStack1)).toMatchInlineSnapshot(`
|
|
46
|
+
"Error0: test1
|
|
47
|
+
at <anonymous> (...)"
|
|
48
|
+
`)
|
|
49
|
+
expect(fixStack(mergedStack2)).toMatchInlineSnapshot(`
|
|
50
|
+
"Error0: test2
|
|
51
|
+
at <anonymous> (...)
|
|
52
|
+
Error0: test1
|
|
53
|
+
at <anonymous> (...)"
|
|
54
|
+
`)
|
|
55
|
+
expect(mergedStack2Public).toBeUndefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('by default serializes stack of this error only', () => {
|
|
59
|
+
const AppError = Error0.use(statusPlugin).use(codePlugin).use(stackMergePlugin())
|
|
60
|
+
const error = new AppError('test', { status: 400, code: 'NOT_FOUND' })
|
|
61
|
+
const json = AppError.serialize(error, false)
|
|
62
|
+
expect(json.stack).toBe(error.stack)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Error0 } from '../index.js'
|
|
2
|
+
|
|
3
|
+
export const stackMergePlugin = ({
|
|
4
|
+
hideWhenPublic = true,
|
|
5
|
+
delimiter = '\n',
|
|
6
|
+
}: { hideWhenPublic?: boolean; delimiter?: string } = {}) =>
|
|
7
|
+
Error0.plugin().stack({
|
|
8
|
+
serialize: ({ error, isPublic }) => {
|
|
9
|
+
if (hideWhenPublic && isPublic) {
|
|
10
|
+
return undefined
|
|
11
|
+
}
|
|
12
|
+
return error
|
|
13
|
+
.causes()
|
|
14
|
+
.map((cause) => {
|
|
15
|
+
return cause instanceof Error ? cause.stack : undefined
|
|
16
|
+
})
|
|
17
|
+
.filter((value): value is string => typeof value === 'string')
|
|
18
|
+
.join(delimiter)
|
|
19
|
+
},
|
|
20
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from 'bun:test'
|
|
2
|
+
import { Error0 } from '../index.js'
|
|
3
|
+
import { statusPlugin } from './status.js'
|
|
4
|
+
|
|
5
|
+
describe('statusPlugin', () => {
|
|
6
|
+
const statuses = {
|
|
7
|
+
BAD_REQUEST: 400,
|
|
8
|
+
UNAUTHORIZED: 401,
|
|
9
|
+
NOT_FOUND: 404,
|
|
10
|
+
} as const
|
|
11
|
+
|
|
12
|
+
it('maps status keys to numeric values', () => {
|
|
13
|
+
const AppError = Error0.use(statusPlugin({ statuses }))
|
|
14
|
+
const error = new AppError('test', { status: 'NOT_FOUND' })
|
|
15
|
+
|
|
16
|
+
expectTypeOf<typeof error.status>().toEqualTypeOf<number | undefined>()
|
|
17
|
+
expect(error.status).toBe(404)
|
|
18
|
+
|
|
19
|
+
const json = AppError.serialize(error, false)
|
|
20
|
+
expect(json.status).toBe(404)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('accepts numeric status values when no statuses map is provided', () => {
|
|
24
|
+
const AppError = Error0.use(statusPlugin())
|
|
25
|
+
const error = new AppError('test', { status: 500 })
|
|
26
|
+
expect(error.status).toBe(500)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('if status number not in list, it is not converts to undefined by default', () => {
|
|
30
|
+
const AppError = Error0.use(statusPlugin({ statuses }))
|
|
31
|
+
const error = new AppError('test', { status: 999 })
|
|
32
|
+
expect(error.status).toBe(999)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('if status number not in list, it converts to undefined if strict is true', () => {
|
|
36
|
+
const AppError = Error0.use(statusPlugin({ statuses, strict: true }))
|
|
37
|
+
const error = new AppError('test', { status: 999 })
|
|
38
|
+
expect(error.status).toBeUndefined()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('not allowed incorrect status name', () => {
|
|
42
|
+
const AppError = Error0.use(statusPlugin({ statuses }))
|
|
43
|
+
// @ts-expect-error - incorrect status name
|
|
44
|
+
const error = new AppError('test', { status: 'SOMETHING_ELSE' })
|
|
45
|
+
expect(error.status).toBeUndefined()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('not allowed status name if statuses not provided', () => {
|
|
49
|
+
const AppError = Error0.use(statusPlugin())
|
|
50
|
+
// @ts-expect-error - incorrect status name
|
|
51
|
+
const error = new AppError('test', { status: 'SOMETHING_ELSE' })
|
|
52
|
+
expect(error.status).toBeUndefined()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Error0 } from '../index.js'
|
|
2
|
+
|
|
3
|
+
export const statusPlugin = <TStatuses extends Record<string, number> = Record<never, number>>({
|
|
4
|
+
statuses,
|
|
5
|
+
strict = false,
|
|
6
|
+
}: { statuses?: TStatuses; strict?: boolean } = {}) => {
|
|
7
|
+
const statusValues = statuses ? Object.values(statuses) : undefined
|
|
8
|
+
const isStatusValue = (value: unknown): value is number =>
|
|
9
|
+
typeof value === 'number' && (!statusValues || !strict || statusValues.includes(value))
|
|
10
|
+
const normalizeStatusValue = (value: unknown): number | undefined => {
|
|
11
|
+
return isStatusValue(value) ? value : undefined
|
|
12
|
+
}
|
|
13
|
+
const convertStatusValue = (value: number | string): number | undefined => {
|
|
14
|
+
if (typeof value === 'number') {
|
|
15
|
+
return normalizeStatusValue(value)
|
|
16
|
+
}
|
|
17
|
+
if (statuses && value in statuses) {
|
|
18
|
+
return statuses[value as keyof TStatuses]
|
|
19
|
+
}
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return Error0.plugin().prop('status', {
|
|
24
|
+
init: (status: number | Extract<keyof TStatuses, string>) => convertStatusValue(status),
|
|
25
|
+
resolve: ({ flow }) => flow.find(Boolean),
|
|
26
|
+
serialize: ({ resolved }) => resolved,
|
|
27
|
+
deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
|
|
28
|
+
})
|
|
29
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from 'bun:test'
|
|
2
|
+
import { Error0 } from '../index.js'
|
|
3
|
+
import { tagsPlugin } from './tags.js'
|
|
4
|
+
|
|
5
|
+
describe('tagsPlugin', () => {
|
|
6
|
+
const tags = ['api', 'db', 'retry'] as const
|
|
7
|
+
|
|
8
|
+
it('merges and deduplicates tags across causes', () => {
|
|
9
|
+
const AppError = Error0.use(tagsPlugin())
|
|
10
|
+
const root = new AppError('root', { tags: ['db', 'retry'] })
|
|
11
|
+
const leaf = new AppError('leaf', { tags: ['api', 'retry'], cause: root })
|
|
12
|
+
expect(leaf.tags).toEqual(['api', 'retry', 'db'])
|
|
13
|
+
expectTypeOf(leaf.tags).toEqualTypeOf<string[] | undefined>()
|
|
14
|
+
expectTypeOf(leaf.own('tags')).toEqualTypeOf<string[] | undefined>()
|
|
15
|
+
expectTypeOf(leaf.flow('tags')).toEqualTypeOf<Array<string[] | undefined>>()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('serializes tags only for private output', () => {
|
|
19
|
+
const AppError = Error0.use(tagsPlugin())
|
|
20
|
+
const error = new AppError('test', { tags: ['internal'] })
|
|
21
|
+
expect(AppError.serialize(error, false).tags).toEqual(['internal'])
|
|
22
|
+
expect('tags' in AppError.serialize(error, true)).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('supports hasTag for single, some, and every checks', () => {
|
|
26
|
+
const AppError = Error0.use(tagsPlugin())
|
|
27
|
+
const root = new AppError('root', { tags: ['db', 'retry'] })
|
|
28
|
+
const leaf = new AppError('leaf', { tags: ['api'], cause: root })
|
|
29
|
+
|
|
30
|
+
expect(AppError.hasTag(leaf, 'db')).toBe(true)
|
|
31
|
+
expect(leaf.hasTag('db')).toBe(true)
|
|
32
|
+
expect(leaf.hasTag('unknown')).toBe(false)
|
|
33
|
+
expect(leaf.hasTag(['api', 'db'], 'some')).toBe(true)
|
|
34
|
+
expect(leaf.hasTag(['api', 'db'], 'every')).toBe(true)
|
|
35
|
+
expect(leaf.hasTag(['api', 'unknown'], 'every')).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('filters tags not in the allow-list when strict is true', () => {
|
|
39
|
+
const AppError = Error0.use(tagsPlugin({ tags: [...tags], strict: true }))
|
|
40
|
+
const recreated = AppError.from({
|
|
41
|
+
name: 'Error0',
|
|
42
|
+
message: 'test',
|
|
43
|
+
tags: ['db', 'invalid', 123],
|
|
44
|
+
})
|
|
45
|
+
expect(recreated.tags).toEqual(['db'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('keeps all string tags when strict is false', () => {
|
|
49
|
+
const AppError = Error0.use(tagsPlugin({ tags: [...tags], strict: false }))
|
|
50
|
+
const recreated = AppError.from({
|
|
51
|
+
name: 'Error0',
|
|
52
|
+
message: 'test',
|
|
53
|
+
tags: ['db', 'custom', 123],
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
expect(recreated.tags).toEqual(['db', 'custom'])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('enforces typed hasTag inputs when allow-list is provided', () => {
|
|
60
|
+
const AppError = Error0.use(tagsPlugin({ tags }))
|
|
61
|
+
const error = new AppError('test', { tags: ['api', 'db'] })
|
|
62
|
+
|
|
63
|
+
expectTypeOf(error.hasTag('api')).toEqualTypeOf<boolean>()
|
|
64
|
+
expectTypeOf(error.hasTag(['api', 'db'], 'every')).toEqualTypeOf<boolean>()
|
|
65
|
+
expectTypeOf(error.hasTag(['api', 'retry'], 'some')).toEqualTypeOf<boolean>()
|
|
66
|
+
|
|
67
|
+
// @ts-expect-error - unknown tag is not part of allow-list
|
|
68
|
+
error.hasTag('custom')
|
|
69
|
+
// @ts-expect-error - array checks require policy argument
|
|
70
|
+
error.hasTag(['api', 'db'])
|
|
71
|
+
// @ts-expect-error - unsupported policy
|
|
72
|
+
error.hasTag(['api', 'db'], 'all')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Error0 } from '../index.js'
|
|
2
|
+
|
|
3
|
+
export const tagsPlugin = <TTag extends string>({
|
|
4
|
+
hideWhenPublic = true,
|
|
5
|
+
tags,
|
|
6
|
+
strict = true,
|
|
7
|
+
}: { hideWhenPublic?: boolean; tags?: TTag[] | readonly TTag[]; strict?: boolean } = {}) => {
|
|
8
|
+
function hasTag(error: Error0, tag: TTag): boolean
|
|
9
|
+
function hasTag(error: Error0, tag: TTag[], policy: 'every' | 'some'): boolean
|
|
10
|
+
function hasTag(error: Error0, tag: TTag | TTag[], policy?: 'every' | 'some'): boolean {
|
|
11
|
+
const tags = (error as any).tags as string[] | undefined
|
|
12
|
+
if (!tags) {
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
if (Array.isArray(tag)) {
|
|
16
|
+
if (policy === 'every') {
|
|
17
|
+
return tag.every((item) => tags.includes(item))
|
|
18
|
+
}
|
|
19
|
+
return tag.some((item) => tags.includes(item))
|
|
20
|
+
}
|
|
21
|
+
return tags.includes(tag)
|
|
22
|
+
}
|
|
23
|
+
const isTag = (value: unknown): value is TTag =>
|
|
24
|
+
typeof value === 'string' && (!tags || !strict || tags.includes(value as TTag))
|
|
25
|
+
return Error0.plugin()
|
|
26
|
+
.prop('tags', {
|
|
27
|
+
init: (input: string[]) => input,
|
|
28
|
+
resolve: ({ flow }) => {
|
|
29
|
+
const merged: string[] = []
|
|
30
|
+
for (const value of flow) {
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
merged.push(...value)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return merged.length > 0 ? Array.from(new Set(merged)) : undefined
|
|
36
|
+
},
|
|
37
|
+
serialize: ({ resolved, isPublic }) => {
|
|
38
|
+
if (hideWhenPublic && isPublic) {
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
return resolved
|
|
42
|
+
},
|
|
43
|
+
deserialize: ({ value }) => {
|
|
44
|
+
if (!Array.isArray(value)) {
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
return value.filter((item) => isTag(item))
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
.method('hasTag', hasTag)
|
|
51
|
+
}
|