Skip to content

Commit 2344406

Browse files
committed
fix: harden isHooks guard, isolate parseResult errors, and route rejections through parseError
1 parent bea3bd1 commit 2344406

File tree

4 files changed

+632
-132
lines changed

4 files changed

+632
-132
lines changed

packages/safe/src/safe/createSafe.test.ts

Lines changed: 114 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2464,7 +2464,8 @@ describe('createSafe', () => {
24642464
})
24652465

24662466
describe('parseResult error handling', () => {
2467-
it('factory parseResult throw is caught by parseError', () => {
2467+
it('factory parseResult throw returns raw result and reports via onHookError', () => {
2468+
const onHookError = vi.fn()
24682469
const appSafe = createSafe({
24692470
parseError: (e) => ({
24702471
code: 'PARSE_ERROR',
@@ -2473,20 +2474,24 @@ describe('createSafe', () => {
24732474
parseResult: () => {
24742475
throw new Error('validation failed')
24752476
},
2477+
onHookError,
24762478
})
24772479

24782480
const [result, error] = appSafe.sync(() => 42)
24792481

2480-
expect(result).toBeNull()
2481-
expect(error).toEqual({ code: 'PARSE_ERROR', message: 'validation failed' })
2482+
expect(result).toBe(42)
2483+
expect(error).toBeNull()
2484+
expect(onHookError).toHaveBeenCalledWith(expect.any(Error), 'parseResult')
24822485
})
24832486

2484-
it('per-call parseResult throw is caught by factory parseError', () => {
2487+
it('per-call parseResult throw returns raw result and reports via onHookError', () => {
2488+
const onHookError = vi.fn()
24852489
const appSafe = createSafe({
24862490
parseError: (e) => ({
24872491
code: 'PARSE_ERROR',
24882492
message: e instanceof Error ? e.message : 'Unknown',
24892493
}),
2494+
onHookError,
24902495
})
24912496

24922497
const [result, error] = appSafe.sync(() => 42, {
@@ -2495,30 +2500,29 @@ describe('createSafe', () => {
24952500
},
24962501
})
24972502

2498-
expect(result).toBeNull()
2499-
expect(error).toEqual({ code: 'PARSE_ERROR', message: 'per-call validation failed' })
2503+
expect(result).toBe(42)
2504+
expect(error).toBeNull()
2505+
expect(onHookError).toHaveBeenCalledWith(expect.any(Error), 'parseResult')
25002506
})
25012507

2502-
it('factory parseResult throw triggers retry on async', async () => {
2503-
let attempts = 0
2504-
const onRetry = vi.fn()
2508+
it('factory parseResult throw does not trigger retry on async', async () => {
2509+
const fn = vi.fn().mockResolvedValue('data')
2510+
const onHookError = vi.fn()
25052511

25062512
const appSafe = createSafe({
25072513
parseError: (e) => String(e),
2508-
parseResult: (response) => {
2509-
attempts++
2510-
if (attempts < 3) throw new Error('not ready')
2511-
return response
2512-
},
2514+
parseResult: () => { throw new Error('not ready') },
25132515
retry: { times: 3 },
2514-
onRetry,
2516+
onHookError,
25152517
})
25162518

2517-
const [result, error] = await appSafe.async(() => Promise.resolve('data'))
2519+
const [result, error] = await appSafe.async(fn)
25182520

2519-
expect(error).toBeNull()
2521+
// parseResult failure is isolated — fn is not retried
2522+
expect(fn).toHaveBeenCalledTimes(1)
25202523
expect(result).toBe('data')
2521-
expect(onRetry).toHaveBeenCalledTimes(2)
2524+
expect(error).toBeNull()
2525+
expect(onHookError).toHaveBeenCalledWith(expect.any(Error), 'parseResult')
25222526
})
25232527

25242528
it('parseResult not called on error path', () => {
@@ -2787,23 +2791,26 @@ describe('createSafe', () => {
27872791
expect(data).toEqual({ only: 'solo' })
27882792
})
27892793

2790-
it('parseResult throwing is caught by parseError in all()', async () => {
2794+
it('parseResult throwing returns raw values in all()', async () => {
2795+
const onHookError = vi.fn()
27912796
const appSafe = createSafe({
27922797
parseError: (e) => ({
27932798
code: 'PARSE_RESULT_ERROR' as const,
27942799
message: e instanceof Error ? e.message : 'unknown',
27952800
}),
27962801
parseResult: () => { throw new Error('parseResult exploded') },
27972802
defaultError: { code: 'PARSE_RESULT_ERROR' as const, message: 'default' },
2803+
onHookError,
27982804
})
27992805

28002806
const [data, error] = await appSafe.all({
28012807
a: () => Promise.resolve(1),
28022808
b: () => Promise.resolve(2),
28032809
})
28042810

2805-
expect(data).toBeNull()
2806-
expect(error).toEqual({ code: 'PARSE_RESULT_ERROR', message: 'parseResult exploded' })
2811+
expect(error).toBeNull()
2812+
expect(data).toEqual({ a: 1, b: 2 })
2813+
expect(onHookError).toHaveBeenCalledWith(expect.any(Error), 'parseResult')
28072814
})
28082815

28092816
it('handles a function that throws synchronously (before returning a promise)', async () => {
@@ -2822,6 +2829,47 @@ describe('createSafe', () => {
28222829
expect(data).toBeNull()
28232830
expect(error).toEqual({ code: 'SYNC_THROW', message: 'sync kaboom' })
28242831
})
2832+
2833+
it('catches a raw promise rejection via defensive handler and applies parseError', async () => {
2834+
// An invalid abortAfter causes validateAbortAfter to throw *before*
2835+
// safeAsync's try-catch, making the returned promise reject outright.
2836+
// The rejection handler on .then() in createSafe.all must catch this
2837+
// and apply parseError (not just toError).
2838+
const appSafe = createSafe({
2839+
parseError: (e) => ({
2840+
code: 'ERR' as const,
2841+
message: e instanceof Error ? e.message : 'unknown',
2842+
}),
2843+
abortAfter: -1,
2844+
})
2845+
2846+
const [data, error] = await appSafe.all({
2847+
a: () => Promise.resolve(1),
2848+
})
2849+
2850+
expect(data).toBeNull()
2851+
expect(error).toEqual({ code: 'ERR', message: expect.stringMatching(/abortAfter/) })
2852+
})
2853+
2854+
it('ignores late rejections after already resolved (rejection handler done guard)', async () => {
2855+
// Both promises reject via invalid abortAfter. The first rejection
2856+
// resolves the outer promise; the second hits `if (done) return`.
2857+
const appSafe = createSafe({
2858+
parseError: (e) => ({
2859+
code: 'ERR' as const,
2860+
message: e instanceof Error ? e.message : 'unknown',
2861+
}),
2862+
abortAfter: -1,
2863+
})
2864+
2865+
const [data, error] = await appSafe.all({
2866+
a: () => Promise.resolve(1),
2867+
b: () => Promise.resolve(2),
2868+
})
2869+
2870+
expect(data).toBeNull()
2871+
expect(error).toEqual({ code: 'ERR', message: expect.stringMatching(/abortAfter/) })
2872+
})
28252873
})
28262874

28272875
describe('allSettled', () => {
@@ -2914,25 +2962,28 @@ describe('createSafe', () => {
29142962
expect(results.only.value).toBe('solo')
29152963
})
29162964

2917-
it('parseResult throwing is caught by parseError in allSettled()', async () => {
2965+
it('parseResult throwing returns raw values in allSettled()', async () => {
2966+
const onHookError = vi.fn()
29182967
const appSafe = createSafe({
29192968
parseError: (e) => ({
29202969
code: 'PARSE_RESULT_ERROR' as const,
29212970
message: e instanceof Error ? e.message : 'unknown',
29222971
}),
29232972
parseResult: () => { throw new Error('parseResult exploded') },
29242973
defaultError: { code: 'PARSE_RESULT_ERROR' as const, message: 'default' },
2974+
onHookError,
29252975
})
29262976

29272977
const results = await appSafe.allSettled({
29282978
a: () => Promise.resolve(1),
29292979
b: () => Promise.resolve(2),
29302980
})
29312981

2932-
expect(results.a.ok).toBe(false)
2933-
expect(results.a.error).toEqual({ code: 'PARSE_RESULT_ERROR', message: 'parseResult exploded' })
2934-
expect(results.b.ok).toBe(false)
2935-
expect(results.b.error).toEqual({ code: 'PARSE_RESULT_ERROR', message: 'parseResult exploded' })
2982+
expect(results.a.ok).toBe(true)
2983+
expect(results.a.value).toBe(1)
2984+
expect(results.b.ok).toBe(true)
2985+
expect(results.b.value).toBe(2)
2986+
expect(onHookError).toHaveBeenCalledWith(expect.any(Error), 'parseResult')
29362987
})
29372988
})
29382989

@@ -3229,4 +3280,41 @@ describe('createSafe', () => {
32293280
})
32303281
})
32313282
})
3283+
3284+
describe('all with empty object', () => {
3285+
it('returns ok with empty object for empty input', async () => {
3286+
const appSafe = createSafe({
3287+
parseError: (e) => (e instanceof Error ? e.message : 'unknown'),
3288+
})
3289+
3290+
const [data, error] = await appSafe.all({})
3291+
3292+
expect(error).toBeNull()
3293+
expect(data).toEqual({})
3294+
})
3295+
3296+
it('result has ok: true for empty input', async () => {
3297+
const appSafe = createSafe({
3298+
parseError: (e) => String(e),
3299+
})
3300+
3301+
const result = await appSafe.all({})
3302+
3303+
expect(result.ok).toBe(true)
3304+
expect(result.value).toEqual({})
3305+
expect(result.error).toBeNull()
3306+
})
3307+
})
3308+
3309+
describe('allSettled with empty object', () => {
3310+
it('returns empty object for empty input', async () => {
3311+
const appSafe = createSafe({
3312+
parseError: (e) => (e instanceof Error ? e.message : 'unknown'),
3313+
})
3314+
3315+
const results = await appSafe.allSettled({})
3316+
3317+
expect(results).toEqual({})
3318+
})
3319+
})
32323320
})

packages/safe/src/safe/createSafe.ts

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { SafeResult, SafeHooks, SafeAsyncHooks, CreateSafeConfig, SafeInstance } from './types'
22
import { ok, err } from './types'
3-
import { safeSync, safeAsync, wrap, wrapAsync, callHook } from './safe'
3+
import { safeSync, safeAsync, wrap, wrapAsync, callHook, toError, callParseError } from './safe'
44

55
// Utility types for typed assertions in all/allSettled (mirrors SafeInstance declarations)
66
type AllValues<
@@ -169,24 +169,31 @@ export function createSafe<E, TResult = never>(config: CreateSafeConfig<E, TResu
169169
let done = false
170170

171171
for (let i = 0; i < promises.length; i++) {
172-
promises[i].then((result) => {
173-
if (done) return
174-
if (!result.ok) {
175-
done = true
176-
resolve(err(result.error) as Result)
177-
return
178-
}
179-
results[i] = result
180-
remaining--
181-
if (remaining === 0) {
182-
done = true
183-
const obj: Record<string, unknown> = {}
184-
for (let j = 0; j < keys.length; j++) {
185-
obj[keys[j]] = results[j].value
172+
promises[i].then(
173+
(result) => {
174+
if (done) return
175+
if (!result.ok) {
176+
done = true
177+
resolve(err(result.error) as Result)
178+
return
186179
}
187-
resolve(ok(obj) as Result)
188-
}
189-
})
180+
results[i] = result
181+
remaining--
182+
if (remaining === 0) {
183+
done = true
184+
const obj: Record<string, unknown> = {}
185+
for (let j = 0; j < keys.length; j++) {
186+
obj[keys[j]] = results[j].value
187+
}
188+
resolve(ok(obj) as Result)
189+
}
190+
},
191+
(rejection) => {
192+
if (done) return
193+
done = true
194+
resolve(err(callParseError(rejection, parseError, defaultOnHookError, defaultError)) as Result)
195+
},
196+
)
190197
}
191198
})
192199
},

0 commit comments

Comments
 (0)